@svrnsec/pulse 0.3.0 → 0.3.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/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 +128 -0
- package/package.json +1 -1
- package/src/proof/fingerprint.js +61 -5
package/dist/pulse.cjs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var module$1 = require('module');
|
|
3
4
|
var node_crypto = require('node:crypto');
|
|
4
5
|
|
|
5
6
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
@@ -781,9 +782,9 @@ async function collectEntropy(opts = {}) {
|
|
|
781
782
|
const hotQE = detectQuantizationEntropy(hotTimings);
|
|
782
783
|
|
|
783
784
|
phases = {
|
|
784
|
-
cold: { n: coldN, timings: coldTimings, qe: coldQE, mean: _mean$
|
|
785
|
-
load: { n: loadN, timings: loadTimings, qe: detectQuantizationEntropy(loadTimings), mean: _mean$
|
|
786
|
-
hot: { n: hotN, timings: hotTimings, qe: hotQE, mean: _mean$
|
|
785
|
+
cold: { n: coldN, timings: coldTimings, qe: coldQE, mean: _mean$4(coldTimings) },
|
|
786
|
+
load: { n: loadN, timings: loadTimings, qe: detectQuantizationEntropy(loadTimings), mean: _mean$4(loadTimings) },
|
|
787
|
+
hot: { n: hotN, timings: hotTimings, qe: hotQE, mean: _mean$4(hotTimings) },
|
|
787
788
|
// The key signal: entropy growth under load.
|
|
788
789
|
// Real silicon: hotQE / coldQE typically 1.05 – 1.40
|
|
789
790
|
// VM: hotQE / coldQE typically 0.95 – 1.05 (flat)
|
|
@@ -837,7 +838,7 @@ async function collectEntropy(opts = {}) {
|
|
|
837
838
|
};
|
|
838
839
|
}
|
|
839
840
|
|
|
840
|
-
function _mean$
|
|
841
|
+
function _mean$4(arr) {
|
|
841
842
|
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
|
|
842
843
|
}
|
|
843
844
|
|
|
@@ -977,11 +978,11 @@ class BioCollector {
|
|
|
977
978
|
|
|
978
979
|
const mouseStats = {
|
|
979
980
|
sampleCount: iei.length,
|
|
980
|
-
ieiMean: _mean(iei),
|
|
981
|
-
ieiCV: _cv(iei),
|
|
981
|
+
ieiMean: _mean$3(iei),
|
|
982
|
+
ieiCV: _cv$1(iei),
|
|
982
983
|
velocityP50: _percentile$1(velocities, 50),
|
|
983
984
|
velocityP95: _percentile$1(velocities, 95),
|
|
984
|
-
angularJerkMean: _mean(angJerk),
|
|
985
|
+
angularJerkMean: _mean$3(angJerk),
|
|
985
986
|
pressureVariance: _variance(pressure),
|
|
986
987
|
};
|
|
987
988
|
|
|
@@ -994,10 +995,10 @@ class BioCollector {
|
|
|
994
995
|
|
|
995
996
|
const keyStats = {
|
|
996
997
|
sampleCount: dwellTimes.length,
|
|
997
|
-
dwellMean: _mean(dwellTimes),
|
|
998
|
-
dwellCV: _cv(dwellTimes),
|
|
999
|
-
ikiMean: _mean(iki),
|
|
1000
|
-
ikiCV: _cv(iki),
|
|
998
|
+
dwellMean: _mean$3(dwellTimes),
|
|
999
|
+
dwellCV: _cv$1(dwellTimes),
|
|
1000
|
+
ikiMean: _mean$3(iki),
|
|
1001
|
+
ikiCV: _cv$1(iki),
|
|
1001
1002
|
};
|
|
1002
1003
|
|
|
1003
1004
|
// ── Interference Coefficient ──────────────────────────────────────────
|
|
@@ -1033,20 +1034,20 @@ class BioCollector {
|
|
|
1033
1034
|
// Statistical helpers (private)
|
|
1034
1035
|
// ---------------------------------------------------------------------------
|
|
1035
1036
|
|
|
1036
|
-
function _mean(arr) {
|
|
1037
|
+
function _mean$3(arr) {
|
|
1037
1038
|
if (!arr.length) return 0;
|
|
1038
1039
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
1039
1040
|
}
|
|
1040
1041
|
|
|
1041
1042
|
function _variance(arr) {
|
|
1042
1043
|
if (arr.length < 2) return 0;
|
|
1043
|
-
const m = _mean(arr);
|
|
1044
|
+
const m = _mean$3(arr);
|
|
1044
1045
|
return arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
|
|
1045
1046
|
}
|
|
1046
1047
|
|
|
1047
|
-
function _cv(arr) {
|
|
1048
|
+
function _cv$1(arr) {
|
|
1048
1049
|
if (!arr.length) return 0;
|
|
1049
|
-
const m = _mean(arr);
|
|
1050
|
+
const m = _mean$3(arr);
|
|
1050
1051
|
if (m === 0) return 0;
|
|
1051
1052
|
return Math.sqrt(_variance(arr)) / Math.abs(m);
|
|
1052
1053
|
}
|
|
@@ -1103,7 +1104,7 @@ function _computeInterference(mouseEvents, keyEvents, timings) {
|
|
|
1103
1104
|
if (!allInputTimes.length) return 0;
|
|
1104
1105
|
|
|
1105
1106
|
const WINDOW_MS = 16;
|
|
1106
|
-
const meanTiming = _mean(timings);
|
|
1107
|
+
const meanTiming = _mean$3(timings);
|
|
1107
1108
|
|
|
1108
1109
|
// We need absolute timestamps for the probe samples.
|
|
1109
1110
|
// We don't have them directly – use relative index spacing as a proxy.
|
|
@@ -1128,8 +1129,8 @@ function _computeInterference(mouseEvents, keyEvents, timings) {
|
|
|
1128
1129
|
function _pearson(X, Y) {
|
|
1129
1130
|
const n = X.length;
|
|
1130
1131
|
if (n < 2) return 0;
|
|
1131
|
-
const mx = _mean(X);
|
|
1132
|
-
const my = _mean(Y);
|
|
1132
|
+
const mx = _mean$3(X);
|
|
1133
|
+
const my = _mean$3(Y);
|
|
1133
1134
|
let num = 0, da = 0, db = 0;
|
|
1134
1135
|
for (let i = 0; i < n; i++) {
|
|
1135
1136
|
const a = X[i] - mx;
|
|
@@ -1746,9 +1747,9 @@ function blake3HexStr(str) {
|
|
|
1746
1747
|
* @param {string} p.nonce – server-issued challenge nonce (hex)
|
|
1747
1748
|
* @returns {ProofPayload}
|
|
1748
1749
|
*/
|
|
1749
|
-
function buildProof({ entropy, jitter, bio, canvas, audio, nonce }) {
|
|
1750
|
+
function buildProof({ entropy, jitter, bio, canvas, audio, enf, gpu, dram, llm, nonce }) {
|
|
1750
1751
|
if (!nonce || typeof nonce !== 'string') {
|
|
1751
|
-
throw new Error('@
|
|
1752
|
+
throw new Error('@svrnsec/pulse: nonce is required for anti-replay protection');
|
|
1752
1753
|
}
|
|
1753
1754
|
|
|
1754
1755
|
// Hash the raw timing arrays IN-BROWSER so we can prove their integrity
|
|
@@ -1830,12 +1831,68 @@ function buildProof({ entropy, jitter, bio, canvas, audio, nonce }) {
|
|
|
1830
1831
|
jitterMeanMs: _round$1(audio.jitterMeanMs, 4),
|
|
1831
1832
|
jitterP95Ms: _round$1(audio.jitterP95Ms, 4),
|
|
1832
1833
|
},
|
|
1834
|
+
|
|
1835
|
+
// ── Electrical Network Frequency ─────────────────────────────────────
|
|
1836
|
+
enf: enf ? {
|
|
1837
|
+
available: enf.enfAvailable,
|
|
1838
|
+
ripplePresent: enf.ripplePresent,
|
|
1839
|
+
gridFrequency: enf.gridFrequency,
|
|
1840
|
+
gridRegion: enf.gridRegion,
|
|
1841
|
+
ripplePower: _round$1(enf.ripplePower, 4),
|
|
1842
|
+
enfDeviation: _round$1(enf.enfDeviation, 3),
|
|
1843
|
+
snr50hz: _round$1(enf.snr50hz, 2),
|
|
1844
|
+
snr60hz: _round$1(enf.snr60hz, 2),
|
|
1845
|
+
sampleRateHz: _round$1(enf.sampleRateHz, 1),
|
|
1846
|
+
verdict: enf.verdict,
|
|
1847
|
+
isVmIndicator: enf.isVmIndicator,
|
|
1848
|
+
capturedAt: enf.temporalAnchor?.capturedAt ?? null,
|
|
1849
|
+
} : null,
|
|
1850
|
+
|
|
1851
|
+
// ── WebGPU thermal variance ───────────────────────────────────────────
|
|
1852
|
+
gpu: gpu ? {
|
|
1853
|
+
available: gpu.gpuPresent,
|
|
1854
|
+
isSoftware: gpu.isSoftware,
|
|
1855
|
+
vendorString: gpu.vendorString,
|
|
1856
|
+
dispatchCV: _round$1(gpu.dispatchCV, 4),
|
|
1857
|
+
thermalGrowth: _round$1(gpu.thermalGrowth, 4),
|
|
1858
|
+
verdict: gpu.verdict,
|
|
1859
|
+
} : null,
|
|
1860
|
+
|
|
1861
|
+
// ── DRAM refresh cycle ────────────────────────────────────────────────
|
|
1862
|
+
dram: dram ? {
|
|
1863
|
+
refreshPresent: dram.refreshPresent,
|
|
1864
|
+
refreshPeriodMs: _round$1(dram.refreshPeriodMs, 2),
|
|
1865
|
+
peakPower: _round$1(dram.peakPower, 4),
|
|
1866
|
+
verdict: dram.verdict,
|
|
1867
|
+
} : null,
|
|
1868
|
+
|
|
1869
|
+
// ── LLM / AI agent behavioral fingerprint ────────────────────────────
|
|
1870
|
+
llm: llm ? {
|
|
1871
|
+
aiConf: _round$1(llm.aiConf, 3),
|
|
1872
|
+
thinkTimePattern: llm.thinkTimePattern,
|
|
1873
|
+
correctionRate: _round$1(llm.correctionRate, 3),
|
|
1874
|
+
rhythmicity: _round$1(llm.rhythmicity, 3),
|
|
1875
|
+
pauseDistribution: llm.pauseDistribution,
|
|
1876
|
+
verdict: llm.verdict,
|
|
1877
|
+
matchedModel: llm.matchedModel ?? null,
|
|
1878
|
+
} : null,
|
|
1833
1879
|
},
|
|
1834
1880
|
|
|
1835
|
-
// Top-level classification summary
|
|
1881
|
+
// Top-level classification summary — all signal layers combined
|
|
1836
1882
|
classification: {
|
|
1837
|
-
jitterScore:
|
|
1838
|
-
flags:
|
|
1883
|
+
jitterScore: _round$1(jitter.score, 4),
|
|
1884
|
+
flags: jitter.flags ?? [],
|
|
1885
|
+
enfVerdict: enf?.verdict ?? 'unavailable',
|
|
1886
|
+
gpuVerdict: gpu?.verdict ?? 'unavailable',
|
|
1887
|
+
dramVerdict: dram?.verdict ?? 'unavailable',
|
|
1888
|
+
llmVerdict: llm?.verdict ?? 'unavailable',
|
|
1889
|
+
// Combined VM confidence: any hard signal raises this
|
|
1890
|
+
vmIndicators: [
|
|
1891
|
+
enf?.isVmIndicator ? 'enf_no_grid' : null,
|
|
1892
|
+
gpu?.isSoftware ? 'gpu_software' : null,
|
|
1893
|
+
dram?.verdict === 'virtual' ? 'dram_no_refresh' : null,
|
|
1894
|
+
llm?.aiConf > 0.7 ? 'llm_agent' : null,
|
|
1895
|
+
].filter(Boolean),
|
|
1839
1896
|
},
|
|
1840
1897
|
};
|
|
1841
1898
|
|
|
@@ -1919,7 +1976,7 @@ function _round$1(v, decimals) {
|
|
|
1919
1976
|
// ---------------------------------------------------------------------------
|
|
1920
1977
|
// Known software-renderer substrings (VM / headless environment indicators)
|
|
1921
1978
|
// ---------------------------------------------------------------------------
|
|
1922
|
-
const SOFTWARE_RENDERER_PATTERNS = [
|
|
1979
|
+
const SOFTWARE_RENDERER_PATTERNS$1 = [
|
|
1923
1980
|
'llvmpipe', 'swiftshader', 'softpipe', 'mesa offscreen',
|
|
1924
1981
|
'microsoft basic render', 'vmware svga', 'virtualbox',
|
|
1925
1982
|
'parallels', 'angle (', 'google swiftshader',
|
|
@@ -1973,7 +2030,7 @@ async function collectCanvasFingerprint() {
|
|
|
1973
2030
|
|
|
1974
2031
|
// Software-renderer detection
|
|
1975
2032
|
const rendererLc = (result.webglRenderer ?? '').toLowerCase();
|
|
1976
|
-
result.isSoftwareRenderer = SOFTWARE_RENDERER_PATTERNS.some(p =>
|
|
2033
|
+
result.isSoftwareRenderer = SOFTWARE_RENDERER_PATTERNS$1.some(p =>
|
|
1977
2034
|
rendererLc.includes(p)
|
|
1978
2035
|
);
|
|
1979
2036
|
|
|
@@ -2143,217 +2200,1798 @@ function _compileShader(gl, type, source) {
|
|
|
2143
2200
|
}
|
|
2144
2201
|
|
|
2145
2202
|
/**
|
|
2146
|
-
* @sovereign/pulse — AudioContext Oscillator Jitter
|
|
2203
|
+
* @sovereign/pulse — AudioContext Oscillator Jitter
|
|
2204
|
+
*
|
|
2205
|
+
* Measures the scheduling jitter of the browser's audio pipeline.
|
|
2206
|
+
* Real audio hardware callbacks are driven by a hardware interrupt (IRQ)
|
|
2207
|
+
* from the sound card; the timing reflects the actual interrupt latency
|
|
2208
|
+
* of the physical device. VM audio drivers (if present at all) are
|
|
2209
|
+
* emulated and show either unrealistically low jitter or burst-mode
|
|
2210
|
+
* scheduling artefacts that are statistically distinguishable.
|
|
2211
|
+
*/
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* @param {object} [opts]
|
|
2215
|
+
* @param {number} [opts.durationMs=2000] - how long to collect audio callbacks
|
|
2216
|
+
* @param {number} [opts.bufferSize=256] - ScriptProcessorNode buffer size
|
|
2217
|
+
* @returns {Promise<AudioJitter>}
|
|
2218
|
+
*/
|
|
2219
|
+
async function collectAudioJitter(opts = {}) {
|
|
2220
|
+
const { durationMs = 2000, bufferSize = 256 } = opts;
|
|
2221
|
+
|
|
2222
|
+
const base = {
|
|
2223
|
+
available: false,
|
|
2224
|
+
workletAvailable: false,
|
|
2225
|
+
callbackJitterCV: 0,
|
|
2226
|
+
noiseFloorMean: 0,
|
|
2227
|
+
sampleRate: 0,
|
|
2228
|
+
callbackCount: 0,
|
|
2229
|
+
jitterTimings: [],
|
|
2230
|
+
};
|
|
2231
|
+
|
|
2232
|
+
if (typeof AudioContext === 'undefined' && typeof webkitAudioContext === 'undefined') {
|
|
2233
|
+
return base; // Node.js / server environment
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
let ctx;
|
|
2237
|
+
try {
|
|
2238
|
+
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
2239
|
+
} catch (_) {
|
|
2240
|
+
return base;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// Some browsers require a user gesture before AudioContext can run.
|
|
2244
|
+
if (ctx.state === 'suspended') {
|
|
2245
|
+
try {
|
|
2246
|
+
await ctx.resume();
|
|
2247
|
+
} catch (_) {
|
|
2248
|
+
await ctx.close().catch(() => {});
|
|
2249
|
+
return base;
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const sampleRate = ctx.sampleRate;
|
|
2254
|
+
const expectedInterval = (bufferSize / sampleRate) * 1000; // ms per callback
|
|
2255
|
+
|
|
2256
|
+
const jitterTimings = []; // absolute AudioContext.currentTime at each callback
|
|
2257
|
+
const callbackDeltas = [];
|
|
2258
|
+
|
|
2259
|
+
await new Promise((resolve) => {
|
|
2260
|
+
// ── AudioWorklet (preferred — runs on dedicated real-time thread) ──────
|
|
2261
|
+
const useWorklet = typeof AudioWorkletNode !== 'undefined';
|
|
2262
|
+
base.workletAvailable = useWorklet;
|
|
2263
|
+
|
|
2264
|
+
if (useWorklet) {
|
|
2265
|
+
// Inline worklet: send currentTime back via MessagePort every buffer
|
|
2266
|
+
const workletCode = `
|
|
2267
|
+
class PulseProbe extends AudioWorkletProcessor {
|
|
2268
|
+
process(inputs, outputs) {
|
|
2269
|
+
this.port.postMessage({ t: currentTime });
|
|
2270
|
+
// Pass-through silence
|
|
2271
|
+
for (const out of outputs)
|
|
2272
|
+
for (const ch of out) ch.fill(0);
|
|
2273
|
+
return true;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
registerProcessor('pulse-probe', PulseProbe);
|
|
2277
|
+
`;
|
|
2278
|
+
const blob = new Blob([workletCode], { type: 'application/javascript' });
|
|
2279
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
2280
|
+
|
|
2281
|
+
ctx.audioWorklet.addModule(blobUrl).then(() => {
|
|
2282
|
+
const node = new AudioWorkletNode(ctx, 'pulse-probe');
|
|
2283
|
+
node.port.onmessage = (e) => {
|
|
2284
|
+
jitterTimings.push(e.data.t * 1000); // convert to ms
|
|
2285
|
+
};
|
|
2286
|
+
node.connect(ctx.destination);
|
|
2287
|
+
|
|
2288
|
+
setTimeout(async () => {
|
|
2289
|
+
node.disconnect();
|
|
2290
|
+
URL.revokeObjectURL(blobUrl);
|
|
2291
|
+
resolve(node);
|
|
2292
|
+
}, durationMs);
|
|
2293
|
+
}).catch(() => {
|
|
2294
|
+
URL.revokeObjectURL(blobUrl);
|
|
2295
|
+
_fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
} else {
|
|
2299
|
+
_fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
// ── Compute deltas between successive callback times ────────────────────
|
|
2304
|
+
for (let i = 1; i < jitterTimings.length; i++) {
|
|
2305
|
+
callbackDeltas.push(jitterTimings[i] - jitterTimings[i - 1]);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// ── Noise floor via AnalyserNode ─────────────────────────────────────────
|
|
2309
|
+
// Feed a silent oscillator through an analyser; the FFT magnitude at silence
|
|
2310
|
+
// reveals the hardware's thermal noise floor (varies per ADC/DAC chipset).
|
|
2311
|
+
const noiseFloor = await _measureNoiseFloor(ctx);
|
|
2312
|
+
|
|
2313
|
+
await ctx.close().catch(() => {});
|
|
2314
|
+
|
|
2315
|
+
// ── Statistics ────────────────────────────────────────────────────────────
|
|
2316
|
+
const mean = callbackDeltas.length
|
|
2317
|
+
? callbackDeltas.reduce((s, v) => s + v, 0) / callbackDeltas.length
|
|
2318
|
+
: 0;
|
|
2319
|
+
const variance = callbackDeltas.length > 1
|
|
2320
|
+
? callbackDeltas.reduce((s, v) => s + (v - mean) ** 2, 0) / (callbackDeltas.length - 1)
|
|
2321
|
+
: 0;
|
|
2322
|
+
const jitterCV = mean > 0 ? Math.sqrt(variance) / mean : 0;
|
|
2323
|
+
|
|
2324
|
+
return {
|
|
2325
|
+
available: true,
|
|
2326
|
+
workletAvailable: base.workletAvailable,
|
|
2327
|
+
callbackJitterCV: jitterCV,
|
|
2328
|
+
noiseFloorMean: noiseFloor.mean,
|
|
2329
|
+
noiseFloorStd: noiseFloor.std,
|
|
2330
|
+
sampleRate,
|
|
2331
|
+
callbackCount: jitterTimings.length,
|
|
2332
|
+
expectedIntervalMs: expectedInterval,
|
|
2333
|
+
// Only include summary stats, not raw timings (privacy / size)
|
|
2334
|
+
jitterMeanMs: mean,
|
|
2335
|
+
jitterP95Ms: _percentile(callbackDeltas, 95),
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* @typedef {object} AudioJitter
|
|
2341
|
+
* @property {boolean} available
|
|
2342
|
+
* @property {boolean} workletAvailable
|
|
2343
|
+
* @property {number} callbackJitterCV
|
|
2344
|
+
* @property {number} noiseFloorMean
|
|
2345
|
+
* @property {number} sampleRate
|
|
2346
|
+
* @property {number} callbackCount
|
|
2347
|
+
*/
|
|
2348
|
+
|
|
2349
|
+
// ---------------------------------------------------------------------------
|
|
2350
|
+
// Internal helpers
|
|
2351
|
+
// ---------------------------------------------------------------------------
|
|
2352
|
+
|
|
2353
|
+
function _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve) {
|
|
2354
|
+
// ScriptProcessorNode is deprecated but universally supported.
|
|
2355
|
+
const proc = ctx.createScriptProcessor(bufferSize, 1, 1);
|
|
2356
|
+
proc.onaudioprocess = () => {
|
|
2357
|
+
jitterTimings.push(ctx.currentTime * 1000);
|
|
2358
|
+
};
|
|
2359
|
+
// Connect to keep the graph alive
|
|
2360
|
+
const osc = ctx.createOscillator();
|
|
2361
|
+
osc.frequency.value = 1; // sub-audible
|
|
2362
|
+
osc.connect(proc);
|
|
2363
|
+
proc.connect(ctx.destination);
|
|
2364
|
+
osc.start();
|
|
2365
|
+
|
|
2366
|
+
setTimeout(() => {
|
|
2367
|
+
osc.stop();
|
|
2368
|
+
osc.disconnect();
|
|
2369
|
+
proc.disconnect();
|
|
2370
|
+
resolve(proc);
|
|
2371
|
+
}, durationMs);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
async function _measureNoiseFloor(ctx) {
|
|
2375
|
+
try {
|
|
2376
|
+
const analyser = ctx.createAnalyser();
|
|
2377
|
+
analyser.fftSize = 256;
|
|
2378
|
+
analyser.connect(ctx.destination);
|
|
2379
|
+
|
|
2380
|
+
// Silent source
|
|
2381
|
+
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);
|
|
2382
|
+
const src = ctx.createBufferSource();
|
|
2383
|
+
src.buffer = buf;
|
|
2384
|
+
src.connect(analyser);
|
|
2385
|
+
src.start();
|
|
2386
|
+
|
|
2387
|
+
await new Promise(r => setTimeout(r, 150));
|
|
2388
|
+
|
|
2389
|
+
const data = new Float32Array(analyser.frequencyBinCount);
|
|
2390
|
+
analyser.getFloatFrequencyData(data);
|
|
2391
|
+
analyser.disconnect();
|
|
2392
|
+
|
|
2393
|
+
// Limit to 32 bins to keep the payload small
|
|
2394
|
+
const trimmed = Array.from(data.slice(0, 32)).map(v =>
|
|
2395
|
+
isFinite(v) ? Math.pow(10, v / 20) : 0 // dB → linear
|
|
2396
|
+
);
|
|
2397
|
+
const mean = trimmed.reduce((s, v) => s + v, 0) / trimmed.length;
|
|
2398
|
+
const std = Math.sqrt(
|
|
2399
|
+
trimmed.reduce((s, v) => s + (v - mean) ** 2, 0) / trimmed.length
|
|
2400
|
+
);
|
|
2401
|
+
return { mean, std };
|
|
2402
|
+
} catch (_) {
|
|
2403
|
+
return { mean: 0, std: 0 };
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function _percentile(arr, p) {
|
|
2408
|
+
if (!arr.length) return 0;
|
|
2409
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
2410
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
2411
|
+
const lo = Math.floor(idx);
|
|
2412
|
+
const hi = Math.ceil(idx);
|
|
2413
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/**
|
|
2417
|
+
* @sovereign/pulse — WebGPU Thermal Variance Probe
|
|
2418
|
+
*
|
|
2419
|
+
* Runs a compute shader on the GPU and measures dispatch timing variance.
|
|
2420
|
+
*
|
|
2421
|
+
* Why this works
|
|
2422
|
+
* ──────────────
|
|
2423
|
+
* Real consumer GPUs (GTX 1650, RX 6600, M2 GPU) have thermal noise in shader
|
|
2424
|
+
* execution timing that increases under sustained load — the same thermodynamic
|
|
2425
|
+
* principle as the CPU probe but in silicon designed for parallel throughput.
|
|
2426
|
+
*
|
|
2427
|
+
* Cloud VMs with software GPU emulation (SwiftShader, llvmpipe, Mesa's softpipe)
|
|
2428
|
+
* execute shaders on the CPU and produce near-deterministic timing — flat CV,
|
|
2429
|
+
* no thermal growth across phases, no dispatch jitter.
|
|
2430
|
+
*
|
|
2431
|
+
* VMs with GPU passthrough (rare in practice, requires dedicated hardware) pass
|
|
2432
|
+
* this check — which is correct, they have real GPU silicon.
|
|
2433
|
+
*
|
|
2434
|
+
* Signals
|
|
2435
|
+
* ───────
|
|
2436
|
+
* gpuPresent false = WebGPU absent = software renderer = high VM probability
|
|
2437
|
+
* isSoftware true = SwiftShader/llvmpipe detected by adapter info
|
|
2438
|
+
* dispatchCV coefficient of variation across dispatch timings
|
|
2439
|
+
* thermalGrowth (hotDispatchMean - coldDispatchMean) / coldDispatchMean
|
|
2440
|
+
* vendorString GPU vendor from adapter info (Intel, NVIDIA, AMD, Apple, etc.)
|
|
2441
|
+
*/
|
|
2442
|
+
|
|
2443
|
+
/* ─── WebGPU availability ────────────────────────────────────────────────── */
|
|
2444
|
+
|
|
2445
|
+
function isWebGPUAvailable() {
|
|
2446
|
+
return typeof navigator !== 'undefined' && 'gpu' in navigator;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
/* ─── Software renderer detection ───────────────────────────────────────── */
|
|
2450
|
+
|
|
2451
|
+
const SOFTWARE_RENDERER_PATTERNS = [
|
|
2452
|
+
/swiftshader/i,
|
|
2453
|
+
/llvmpipe/i,
|
|
2454
|
+
/softpipe/i,
|
|
2455
|
+
/microsoft basic render/i,
|
|
2456
|
+
/angle \(.*software/i,
|
|
2457
|
+
/cpu/i,
|
|
2458
|
+
];
|
|
2459
|
+
|
|
2460
|
+
function detectSoftwareRenderer(adapterInfo) {
|
|
2461
|
+
const desc = [
|
|
2462
|
+
adapterInfo?.vendor ?? '',
|
|
2463
|
+
adapterInfo?.device ?? '',
|
|
2464
|
+
adapterInfo?.description ?? '',
|
|
2465
|
+
adapterInfo?.architecture ?? '',
|
|
2466
|
+
].join(' ');
|
|
2467
|
+
|
|
2468
|
+
return SOFTWARE_RENDERER_PATTERNS.some(p => p.test(desc));
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/* ─── Compute shader ─────────────────────────────────────────────────────── */
|
|
2472
|
+
|
|
2473
|
+
// A compute workload that is trivially parallelisable but forces the GPU to
|
|
2474
|
+
// actually execute — matrix-multiply on 64 × 64 tiles across 256 workgroups.
|
|
2475
|
+
// Light enough that it doesn't block UI; heavy enough to generate thermal signal.
|
|
2476
|
+
const SHADER_SRC = /* wgsl */ `
|
|
2477
|
+
struct Matrix {
|
|
2478
|
+
values: array<f32, 4096>, // 64x64
|
|
2479
|
+
};
|
|
2480
|
+
|
|
2481
|
+
@group(0) @binding(0) var<storage, read> matA : Matrix;
|
|
2482
|
+
@group(0) @binding(1) var<storage, read> matB : Matrix;
|
|
2483
|
+
@group(0) @binding(2) var<storage, read_write> matC : Matrix;
|
|
2484
|
+
|
|
2485
|
+
@compute @workgroup_size(8, 8)
|
|
2486
|
+
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
2487
|
+
let row = gid.x;
|
|
2488
|
+
let col = gid.y;
|
|
2489
|
+
if (row >= 64u || col >= 64u) { return; }
|
|
2490
|
+
|
|
2491
|
+
var acc: f32 = 0.0;
|
|
2492
|
+
for (var k = 0u; k < 64u; k++) {
|
|
2493
|
+
acc += matA.values[row * 64u + k] * matB.values[k * 64u + col];
|
|
2494
|
+
}
|
|
2495
|
+
matC.values[row * 64u + col] = acc;
|
|
2496
|
+
}
|
|
2497
|
+
`;
|
|
2498
|
+
|
|
2499
|
+
/* ─── collectGpuEntropy ─────────────────────────────────────────────────── */
|
|
2500
|
+
|
|
2501
|
+
/**
|
|
2502
|
+
* @param {object} [opts]
|
|
2503
|
+
* @param {number} [opts.iterations=60] – dispatch rounds per phase
|
|
2504
|
+
* @param {boolean} [opts.phased=true] – cold / load / hot phases
|
|
2505
|
+
* @param {number} [opts.timeoutMs=8000] – hard abort if GPU stalls
|
|
2506
|
+
* @returns {Promise<GpuEntropyResult>}
|
|
2507
|
+
*/
|
|
2508
|
+
async function collectGpuEntropy(opts = {}) {
|
|
2509
|
+
const { iterations = 60, phased = true, timeoutMs = 8000 } = opts;
|
|
2510
|
+
|
|
2511
|
+
if (!isWebGPUAvailable()) {
|
|
2512
|
+
return _noGpu('WebGPU not available in this environment');
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
let adapter, device;
|
|
2516
|
+
try {
|
|
2517
|
+
adapter = await Promise.race([
|
|
2518
|
+
navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }),
|
|
2519
|
+
_timeout(timeoutMs, 'requestAdapter timed out'),
|
|
2520
|
+
]);
|
|
2521
|
+
if (!adapter) return _noGpu('No WebGPU adapter found');
|
|
2522
|
+
|
|
2523
|
+
device = await Promise.race([
|
|
2524
|
+
adapter.requestDevice(),
|
|
2525
|
+
_timeout(timeoutMs, 'requestDevice timed out'),
|
|
2526
|
+
]);
|
|
2527
|
+
} catch (err) {
|
|
2528
|
+
return _noGpu(`WebGPU init failed: ${err.message}`);
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
const adapterInfo = adapter.info ?? {};
|
|
2532
|
+
const isSoftware = detectSoftwareRenderer(adapterInfo);
|
|
2533
|
+
|
|
2534
|
+
// Compile the shader module once
|
|
2535
|
+
const shaderModule = device.createShaderModule({ code: SHADER_SRC });
|
|
2536
|
+
|
|
2537
|
+
// Create persistent GPU buffers (64×64 float32 = 16 KB each)
|
|
2538
|
+
const bufSize = 4096 * 4; // 4096 floats × 4 bytes
|
|
2539
|
+
const bufA = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
2540
|
+
const bufB = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
|
2541
|
+
const bufC = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
|
2542
|
+
|
|
2543
|
+
// Seed with random data
|
|
2544
|
+
const matData = new Float32Array(4096).map(() => Math.random());
|
|
2545
|
+
device.queue.writeBuffer(bufA, 0, matData);
|
|
2546
|
+
device.queue.writeBuffer(bufB, 0, matData);
|
|
2547
|
+
|
|
2548
|
+
const pipeline = device.createComputePipeline({
|
|
2549
|
+
layout: 'auto',
|
|
2550
|
+
compute: { module: shaderModule, entryPoint: 'main' },
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
const bindGroup = device.createBindGroup({
|
|
2554
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
2555
|
+
entries: [
|
|
2556
|
+
{ binding: 0, resource: { buffer: bufA } },
|
|
2557
|
+
{ binding: 1, resource: { buffer: bufB } },
|
|
2558
|
+
{ binding: 2, resource: { buffer: bufC } },
|
|
2559
|
+
],
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2562
|
+
// ── Probe ──────────────────────────────────────────────────────────────
|
|
2563
|
+
async function runPhase(n) {
|
|
2564
|
+
const timings = [];
|
|
2565
|
+
for (let i = 0; i < n; i++) {
|
|
2566
|
+
const t0 = performance.now();
|
|
2567
|
+
const encoder = device.createCommandEncoder();
|
|
2568
|
+
const pass = encoder.beginComputePass();
|
|
2569
|
+
pass.setPipeline(pipeline);
|
|
2570
|
+
pass.setBindGroup(0, bindGroup);
|
|
2571
|
+
pass.dispatchWorkgroups(8, 8); // 64 workgroups total
|
|
2572
|
+
pass.end();
|
|
2573
|
+
device.queue.submit([encoder.finish()]);
|
|
2574
|
+
await device.queue.onSubmittedWorkDone();
|
|
2575
|
+
const t1 = performance.now();
|
|
2576
|
+
timings.push(t1 - t0);
|
|
2577
|
+
}
|
|
2578
|
+
return timings;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
let coldTimings, loadTimings, hotTimings;
|
|
2582
|
+
|
|
2583
|
+
if (phased) {
|
|
2584
|
+
coldTimings = await runPhase(Math.floor(iterations * 0.25));
|
|
2585
|
+
loadTimings = await runPhase(Math.floor(iterations * 0.50));
|
|
2586
|
+
hotTimings = await runPhase(iterations - coldTimings.length - loadTimings.length);
|
|
2587
|
+
} else {
|
|
2588
|
+
coldTimings = await runPhase(iterations);
|
|
2589
|
+
loadTimings = [];
|
|
2590
|
+
hotTimings = [];
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// Cleanup
|
|
2594
|
+
bufA.destroy(); bufB.destroy(); bufC.destroy();
|
|
2595
|
+
device.destroy();
|
|
2596
|
+
|
|
2597
|
+
const allTimings = [...coldTimings, ...loadTimings, ...hotTimings];
|
|
2598
|
+
const mean = _mean$2(allTimings);
|
|
2599
|
+
const cv = mean > 0 ? _std$1(allTimings) / mean : 0;
|
|
2600
|
+
|
|
2601
|
+
const coldMean = _mean$2(coldTimings);
|
|
2602
|
+
const hotMean = _mean$2(hotTimings.length ? hotTimings : coldTimings);
|
|
2603
|
+
const thermalGrowth = coldMean > 0 ? (hotMean - coldMean) / coldMean : 0;
|
|
2604
|
+
|
|
2605
|
+
return {
|
|
2606
|
+
gpuPresent: true,
|
|
2607
|
+
isSoftware,
|
|
2608
|
+
vendor: adapterInfo.vendor ?? 'unknown',
|
|
2609
|
+
architecture: adapterInfo.architecture ?? 'unknown',
|
|
2610
|
+
timings: allTimings,
|
|
2611
|
+
dispatchCV: cv,
|
|
2612
|
+
thermalGrowth,
|
|
2613
|
+
coldMean,
|
|
2614
|
+
hotMean,
|
|
2615
|
+
// Heuristic: real GPU → thermalGrowth > 0.02 and CV > 0.04
|
|
2616
|
+
// Software renderer → thermalGrowth ≈ 0, CV < 0.02
|
|
2617
|
+
verdict: isSoftware ? 'software_renderer'
|
|
2618
|
+
: thermalGrowth > 0.02 && cv > 0.04 ? 'real_gpu'
|
|
2619
|
+
: thermalGrowth < 0 && cv < 0.02 ? 'virtual_gpu'
|
|
2620
|
+
: 'ambiguous',
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
/* ─── helpers ────────────────────────────────────────────────────────────── */
|
|
2625
|
+
|
|
2626
|
+
function _noGpu(reason) {
|
|
2627
|
+
return { gpuPresent: false, isSoftware: false, vendor: null,
|
|
2628
|
+
architecture: null, timings: [], dispatchCV: 0,
|
|
2629
|
+
thermalGrowth: 0, coldMean: 0, hotMean: 0,
|
|
2630
|
+
verdict: 'no_gpu', reason };
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function _createBuffer(device, size, usage) {
|
|
2634
|
+
return device.createBuffer({ size, usage });
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
function _mean$2(arr) {
|
|
2638
|
+
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
function _std$1(arr) {
|
|
2642
|
+
const m = _mean$2(arr);
|
|
2643
|
+
return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
function _timeout(ms, msg) {
|
|
2647
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms));
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
/**
|
|
2651
|
+
* @typedef {object} GpuEntropyResult
|
|
2652
|
+
* @property {boolean} gpuPresent
|
|
2653
|
+
* @property {boolean} isSoftware
|
|
2654
|
+
* @property {string|null} vendor
|
|
2655
|
+
* @property {string|null} architecture
|
|
2656
|
+
* @property {number[]} timings
|
|
2657
|
+
* @property {number} dispatchCV
|
|
2658
|
+
* @property {number} thermalGrowth
|
|
2659
|
+
* @property {string} verdict 'real_gpu' | 'virtual_gpu' | 'software_renderer' | 'no_gpu' | 'ambiguous'
|
|
2660
|
+
*/
|
|
2661
|
+
|
|
2662
|
+
/**
|
|
2663
|
+
* @sovereign/pulse — DRAM Refresh Cycle Detector
|
|
2664
|
+
*
|
|
2665
|
+
* DDR4 DRAM refreshes every 7.8 ms (tREFI per JEDEC JESD79-4). During a
|
|
2666
|
+
* refresh, the memory controller stalls all access requests for ~350 ns.
|
|
2667
|
+
* In a tight sequential memory access loop this appears as a periodic
|
|
2668
|
+
* slowdown — detectable as a ~128Hz peak in the autocorrelation of access
|
|
2669
|
+
* timings.
|
|
2670
|
+
*
|
|
2671
|
+
* Virtual machines do not have physical DRAM. The hypervisor's memory
|
|
2672
|
+
* subsystem does not reproduce the refresh cycle because:
|
|
2673
|
+
* 1. The guest never touches real DRAM directly — there is always a
|
|
2674
|
+
* hypervisor-controlled indirection layer.
|
|
2675
|
+
* 2. EPT/NPT (Extended/Nested Page Tables) absorb the timing.
|
|
2676
|
+
* 3. The hypervisor's memory balloon driver further smooths access latency.
|
|
2677
|
+
*
|
|
2678
|
+
* What we detect
|
|
2679
|
+
* ──────────────
|
|
2680
|
+
* refreshPeriodMs estimated DRAM refresh period (should be ~7.8ms on real DDR4)
|
|
2681
|
+
* refreshPresent true if the ~7.8ms periodicity is statistically significant
|
|
2682
|
+
* peakLag autocorrelation lag with the highest power (units: sample index)
|
|
2683
|
+
* peakPower autocorrelation power at peakLag (0–1)
|
|
2684
|
+
* verdict 'dram' | 'virtual' | 'ambiguous'
|
|
2685
|
+
*
|
|
2686
|
+
* Calibration
|
|
2687
|
+
* ───────────
|
|
2688
|
+
* We allocate a buffer large enough to exceed all CPU caches (typically
|
|
2689
|
+
* L3 = 8–32 MB on consumer parts). Sequential reads then go to DRAM, not
|
|
2690
|
+
* cache. The refresh stall is only visible when we're actually hitting DRAM —
|
|
2691
|
+
* a cache-resident access loop shows no refresh signal.
|
|
2692
|
+
*
|
|
2693
|
+
* Buffer size: 64 MB — comfortably above L3 on all tested platforms.
|
|
2694
|
+
* Sampling interval: ~1 ms per iteration (chosen to resolve 7.8ms at ≥8 pts).
|
|
2695
|
+
* Total probe time: ~400 ms — well within the fingerprint collection window.
|
|
2696
|
+
*/
|
|
2697
|
+
|
|
2698
|
+
const DRAM_REFRESH_MS = 7.8; // JEDEC DDR4 nominal
|
|
2699
|
+
const DRAM_REFRESH_SLACK = 1.5; // ±1.5 ms acceptable range for real hardware
|
|
2700
|
+
const BUFFER_MB = 64; // must exceed L3 cache
|
|
2701
|
+
const PROBE_ITERATIONS$1 = 400; // ~400 ms total
|
|
2702
|
+
|
|
2703
|
+
/* ─── collectDramTimings ─────────────────────────────────────────────────── */
|
|
2704
|
+
|
|
2705
|
+
/**
|
|
2706
|
+
* @param {object} [opts]
|
|
2707
|
+
* @param {number} [opts.iterations=400]
|
|
2708
|
+
* @param {number} [opts.bufferMb=64]
|
|
2709
|
+
* @returns {{ timings: number[], refreshPeriodMs: number|null,
|
|
2710
|
+
* refreshPresent: boolean, peakLag: number, peakPower: number,
|
|
2711
|
+
* verdict: string }}
|
|
2712
|
+
*/
|
|
2713
|
+
function collectDramTimings(opts = {}) {
|
|
2714
|
+
const {
|
|
2715
|
+
iterations = PROBE_ITERATIONS$1,
|
|
2716
|
+
bufferMb = BUFFER_MB,
|
|
2717
|
+
} = opts;
|
|
2718
|
+
|
|
2719
|
+
// ── Allocate cache-busting buffer ────────────────────────────────────────
|
|
2720
|
+
const nElements = (bufferMb * 1024 * 1024) / 8; // 64-bit doubles
|
|
2721
|
+
let buf;
|
|
2722
|
+
|
|
2723
|
+
try {
|
|
2724
|
+
buf = new Float64Array(nElements);
|
|
2725
|
+
// Touch every cache line to ensure OS actually maps the pages
|
|
2726
|
+
const stride = 64 / 8; // 64-byte cache lines, 8 bytes per element
|
|
2727
|
+
for (let i = 0; i < nElements; i += stride) buf[i] = i;
|
|
2728
|
+
} catch {
|
|
2729
|
+
// Allocation failure (memory constrained) — cannot run this probe
|
|
2730
|
+
return _noSignal('buffer allocation failed');
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// ── Sequential access loop ───────────────────────────────────────────────
|
|
2734
|
+
// Each iteration does a full sequential pass over `passElements` worth of
|
|
2735
|
+
// the buffer. Pass size is tuned so each iteration takes ~1 ms wall-clock,
|
|
2736
|
+
// giving us enough resolution to see the 7.8 ms refresh cycle.
|
|
2737
|
+
//
|
|
2738
|
+
// We start with a small pass and auto-calibrate to hit the 1 ms target.
|
|
2739
|
+
const passElements = _calibratePassSize(buf);
|
|
2740
|
+
|
|
2741
|
+
const timings = new Float64Array(iterations);
|
|
2742
|
+
let checksum = 0;
|
|
2743
|
+
|
|
2744
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
2745
|
+
const t0 = performance.now();
|
|
2746
|
+
for (let i = 0; i < passElements; i++) checksum += buf[i];
|
|
2747
|
+
timings[iter] = performance.now() - t0;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// Prevent dead-code elimination
|
|
2751
|
+
if (checksum === 0) buf[0] = 1;
|
|
2752
|
+
|
|
2753
|
+
// ── Autocorrelation over timings ─────────────────────────────────────────
|
|
2754
|
+
// The refresh stall appears as elevated autocorrelation at lag ≈ 7.8 / Δt
|
|
2755
|
+
// where Δt is the mean iteration time in ms.
|
|
2756
|
+
const meanIterMs = _mean$1(timings);
|
|
2757
|
+
if (meanIterMs <= 0) return _noSignal('zero mean iteration time');
|
|
2758
|
+
|
|
2759
|
+
const targetLag = Math.round(DRAM_REFRESH_MS / meanIterMs);
|
|
2760
|
+
const maxLag = Math.min(Math.round(50 / meanIterMs), iterations >> 1);
|
|
2761
|
+
|
|
2762
|
+
const ac = _autocorr(Array.from(timings), maxLag);
|
|
2763
|
+
|
|
2764
|
+
// Find the peak in the range [targetLag ± slack]
|
|
2765
|
+
const slackLags = Math.round(DRAM_REFRESH_SLACK / meanIterMs);
|
|
2766
|
+
const lagLo = Math.max(1, targetLag - slackLags);
|
|
2767
|
+
const lagHi = Math.min(maxLag, targetLag + slackLags);
|
|
2768
|
+
|
|
2769
|
+
let peakPower = -Infinity;
|
|
2770
|
+
let peakLag = targetLag;
|
|
2771
|
+
for (let l = lagLo; l <= lagHi; l++) {
|
|
2772
|
+
if (ac[l - 1] > peakPower) {
|
|
2773
|
+
peakPower = ac[l - 1];
|
|
2774
|
+
peakLag = l;
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// Baseline: average autocorrelation outside the refresh window
|
|
2779
|
+
const baseline = _mean$1(
|
|
2780
|
+
Array.from({ length: maxLag }, (_, i) => ac[i])
|
|
2781
|
+
.filter((_, i) => i + 1 < lagLo || i + 1 > lagHi)
|
|
2782
|
+
);
|
|
2783
|
+
|
|
2784
|
+
const snr = baseline > 0 ? peakPower / baseline : 0;
|
|
2785
|
+
const refreshPresent = peakPower > 0.15 && snr > 1.8;
|
|
2786
|
+
const refreshPeriodMs = refreshPresent ? peakLag * meanIterMs : null;
|
|
2787
|
+
|
|
2788
|
+
const verdict =
|
|
2789
|
+
refreshPresent && refreshPeriodMs !== null &&
|
|
2790
|
+
Math.abs(refreshPeriodMs - DRAM_REFRESH_MS) < DRAM_REFRESH_SLACK
|
|
2791
|
+
? 'dram'
|
|
2792
|
+
: peakPower < 0.05
|
|
2793
|
+
? 'virtual'
|
|
2794
|
+
: 'ambiguous';
|
|
2795
|
+
|
|
2796
|
+
return {
|
|
2797
|
+
timings: Array.from(timings),
|
|
2798
|
+
refreshPeriodMs,
|
|
2799
|
+
refreshPresent,
|
|
2800
|
+
peakLag,
|
|
2801
|
+
peakPower: +peakPower.toFixed(4),
|
|
2802
|
+
snr: +snr.toFixed(2),
|
|
2803
|
+
meanIterMs: +meanIterMs.toFixed(3),
|
|
2804
|
+
verdict,
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/* ─── helpers ────────────────────────────────────────────────────────────── */
|
|
2809
|
+
|
|
2810
|
+
function _noSignal(reason) {
|
|
2811
|
+
return {
|
|
2812
|
+
timings: [], refreshPeriodMs: null, refreshPresent: false,
|
|
2813
|
+
peakLag: 0, peakPower: 0, snr: 0, meanIterMs: 0,
|
|
2814
|
+
verdict: 'ambiguous', reason,
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
/**
|
|
2819
|
+
* Run a quick calibration pass to find how many elements to read per
|
|
2820
|
+
* iteration so each iteration takes approximately 1 ms.
|
|
2821
|
+
*/
|
|
2822
|
+
function _calibratePassSize(buf) {
|
|
2823
|
+
const target = 1.0; // ms
|
|
2824
|
+
let n = Math.min(100_000, buf.length);
|
|
2825
|
+
let elapsed = 0;
|
|
2826
|
+
let dummy = 0;
|
|
2827
|
+
|
|
2828
|
+
// Warm up
|
|
2829
|
+
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
2830
|
+
|
|
2831
|
+
// Measure
|
|
2832
|
+
const t0 = performance.now();
|
|
2833
|
+
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
2834
|
+
elapsed = performance.now() - t0;
|
|
2835
|
+
if (dummy === 0) buf[0] = 1; // prevent DCE
|
|
2836
|
+
|
|
2837
|
+
if (elapsed <= 0) return n;
|
|
2838
|
+
return Math.min(buf.length, Math.round(n * (target / elapsed)));
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function _mean$1(arr) {
|
|
2842
|
+
if (!arr.length) return 0;
|
|
2843
|
+
return arr.reduce((s, v) => s + v, 0) / arr.length;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
function _autocorr(data, maxLag) {
|
|
2847
|
+
const n = data.length;
|
|
2848
|
+
const mean = _mean$1(data);
|
|
2849
|
+
let v = 0;
|
|
2850
|
+
for (let i = 0; i < n; i++) v += (data[i] - mean) ** 2;
|
|
2851
|
+
v /= n;
|
|
2852
|
+
|
|
2853
|
+
const result = new Float64Array(maxLag);
|
|
2854
|
+
if (v < 1e-14) return result;
|
|
2855
|
+
|
|
2856
|
+
for (let lag = 1; lag <= maxLag; lag++) {
|
|
2857
|
+
let cov = 0;
|
|
2858
|
+
for (let i = 0; i < n - lag; i++) {
|
|
2859
|
+
cov += (data[i] - mean) * (data[i + lag] - mean);
|
|
2860
|
+
}
|
|
2861
|
+
result[lag - 1] = cov / ((n - lag) * v);
|
|
2862
|
+
}
|
|
2863
|
+
return result;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/**
|
|
2867
|
+
* @sovereign/pulse — SharedArrayBuffer Microsecond Timer
|
|
2868
|
+
*
|
|
2869
|
+
* Bypasses browser timer clamping (Brave 100µs cap, Firefox 20µs cap, Safari
|
|
2870
|
+
* 1ms cap) using Atomics.wait() which is exempt from clamping because it maps
|
|
2871
|
+
* directly to OS-level futex/semaphore primitives.
|
|
2872
|
+
*
|
|
2873
|
+
* Requirements
|
|
2874
|
+
* ────────────
|
|
2875
|
+
* The page must be served with Cross-Origin Isolation headers:
|
|
2876
|
+
* Cross-Origin-Opener-Policy: same-origin
|
|
2877
|
+
* Cross-Origin-Embedder-Policy: require-corp
|
|
2878
|
+
*
|
|
2879
|
+
* These are mandatory for security (Spectre mitigations) and are already
|
|
2880
|
+
* required by WebGPU, WebAssembly threads, and SharedArrayBuffer in all
|
|
2881
|
+
* modern browsers.
|
|
2882
|
+
*
|
|
2883
|
+
* What we measure
|
|
2884
|
+
* ───────────────
|
|
2885
|
+
* resolution the true timer resolution (pre-clamp) in microseconds
|
|
2886
|
+
* isClamped true if performance.now() is artificially reduced
|
|
2887
|
+
* clampAmount how much performance.now() was rounded (µs)
|
|
2888
|
+
* highResTimings entropy probe timings at true microsecond resolution
|
|
2889
|
+
*
|
|
2890
|
+
* Why this matters
|
|
2891
|
+
* ────────────────
|
|
2892
|
+
* With 1ms clamping, a VM's flat distribution and a real device's noisy
|
|
2893
|
+
* distribution can look similar — both get quantized to the same step.
|
|
2894
|
+
* At 1µs resolution, the difference between EJR=1.01 and EJR=1.24 is
|
|
2895
|
+
* unmistakable. This upgrade alone materially improves detection accuracy
|
|
2896
|
+
* on Brave and Firefox where timer clamping was previously a confound.
|
|
2897
|
+
*/
|
|
2898
|
+
|
|
2899
|
+
/* ─── availability ───────────────────────────────────────────────────────── */
|
|
2900
|
+
|
|
2901
|
+
function isSabAvailable() {
|
|
2902
|
+
return (
|
|
2903
|
+
typeof SharedArrayBuffer !== 'undefined' &&
|
|
2904
|
+
typeof Atomics !== 'undefined' &&
|
|
2905
|
+
typeof Atomics.wait === 'function' &&
|
|
2906
|
+
crossOriginIsolated === true // window flag set by COOP+COEP headers
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
/* ─── Atomics-based high-resolution clock ───────────────────────────────── */
|
|
2911
|
+
|
|
2912
|
+
let _sab = null;
|
|
2913
|
+
let _i32 = null;
|
|
2914
|
+
|
|
2915
|
+
function _initSab() {
|
|
2916
|
+
if (!_sab) {
|
|
2917
|
+
_sab = new SharedArrayBuffer(4);
|
|
2918
|
+
_i32 = new Int32Array(_sab);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
/**
|
|
2923
|
+
* Wait exactly `us` microseconds using Atomics.wait().
|
|
2924
|
+
* Returns wall-clock elapsed in milliseconds.
|
|
2925
|
+
* Much more accurate than setTimeout(fn, 0) or performance.now() loops.
|
|
2926
|
+
*
|
|
2927
|
+
* @param {number} us – microseconds to wait
|
|
2928
|
+
* @returns {number} actual elapsed ms
|
|
2929
|
+
*/
|
|
2930
|
+
function _atomicsWait(us) {
|
|
2931
|
+
_initSab();
|
|
2932
|
+
const t0 = performance.now();
|
|
2933
|
+
Atomics.wait(_i32, 0, 0, us / 1000); // Atomics.wait timeout is in ms
|
|
2934
|
+
return performance.now() - t0;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
/* ─── measureClamp ───────────────────────────────────────────────────────── */
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* Determine the true timer resolution by comparing a series of
|
|
2941
|
+
* sub-millisecond Atomics.wait() calls against performance.now() deltas.
|
|
2942
|
+
*
|
|
2943
|
+
* @returns {{ isClamped: boolean, clampAmountUs: number, resolutionUs: number }}
|
|
2944
|
+
*/
|
|
2945
|
+
function measureClamp() {
|
|
2946
|
+
if (!isSabAvailable()) {
|
|
2947
|
+
return { isClamped: false, clampAmountUs: 0, resolutionUs: 1000 };
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// Measure the minimum non-zero performance.now() delta
|
|
2951
|
+
const performanceDeltas = [];
|
|
2952
|
+
for (let i = 0; i < 100; i++) {
|
|
2953
|
+
const t0 = performance.now();
|
|
2954
|
+
let t1 = t0;
|
|
2955
|
+
while (t1 === t0) t1 = performance.now();
|
|
2956
|
+
performanceDeltas.push((t1 - t0) * 1000); // convert to µs
|
|
2957
|
+
}
|
|
2958
|
+
performanceDeltas.sort((a, b) => a - b);
|
|
2959
|
+
const perfResolutionUs = performanceDeltas[Math.floor(performanceDeltas.length * 0.1)]; // 10th percentile
|
|
2960
|
+
|
|
2961
|
+
// Measure actual OS timer resolution via Atomics.wait
|
|
2962
|
+
const atomicsDeltas = [];
|
|
2963
|
+
for (let i = 0; i < 20; i++) {
|
|
2964
|
+
const elapsedMs = _atomicsWait(100); // wait 100µs
|
|
2965
|
+
atomicsDeltas.push(Math.abs(elapsedMs * 1000 - 100)); // error from target
|
|
2966
|
+
}
|
|
2967
|
+
const atomicsErrorUs = atomicsDeltas.reduce((s, v) => s + v, 0) / atomicsDeltas.length;
|
|
2968
|
+
const trueResolutionUs = Math.max(1, atomicsErrorUs);
|
|
2969
|
+
|
|
2970
|
+
const isClamped = perfResolutionUs > trueResolutionUs * 5;
|
|
2971
|
+
const clampAmountUs = isClamped ? perfResolutionUs - trueResolutionUs : 0;
|
|
2972
|
+
|
|
2973
|
+
return { isClamped, clampAmountUs, resolutionUs: perfResolutionUs };
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
/* ─── collectHighResTimings ──────────────────────────────────────────────── */
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* Collect entropy probe timings at Atomics-level resolution.
|
|
2980
|
+
* Falls back to performance.now() if SAB is unavailable.
|
|
2981
|
+
*
|
|
2982
|
+
* The probe itself is identical to the WASM matrix probe — CPU work unit
|
|
2983
|
+
* timed with the highest available clock. The difference: on a clamped
|
|
2984
|
+
* browser this replaces quantized 100µs buckets with true µs measurements.
|
|
2985
|
+
*
|
|
2986
|
+
* @param {object} opts
|
|
2987
|
+
* @param {number} [opts.iterations=200]
|
|
2988
|
+
* @param {number} [opts.matrixSize=32] – smaller than WASM probe (no SIMD here)
|
|
2989
|
+
* @returns {{ timings: number[], usingAtomics: boolean, resolutionUs: number }}
|
|
2990
|
+
*/
|
|
2991
|
+
function collectHighResTimings(opts = {}) {
|
|
2992
|
+
const { iterations = 200, matrixSize = 32 } = opts;
|
|
2993
|
+
|
|
2994
|
+
const usingAtomics = isSabAvailable();
|
|
2995
|
+
const clampInfo = usingAtomics ? measureClamp() : { resolutionUs: 1000 };
|
|
2996
|
+
|
|
2997
|
+
// Simple matrix multiply work unit (JS — no WASM needed for the clock probe)
|
|
2998
|
+
const N = matrixSize;
|
|
2999
|
+
const A = new Float64Array(N * N).map(() => Math.random());
|
|
3000
|
+
const B = new Float64Array(N * N).map(() => Math.random());
|
|
3001
|
+
const C = new Float64Array(N * N);
|
|
3002
|
+
|
|
3003
|
+
const timings = new Array(iterations);
|
|
3004
|
+
|
|
3005
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
3006
|
+
C.fill(0);
|
|
3007
|
+
|
|
3008
|
+
if (usingAtomics) {
|
|
3009
|
+
// ── Atomics path: start timing, do work, read Atomics-calibrated time ──
|
|
3010
|
+
// We use a sliding window approach: measure with Atomics.wait(0) which
|
|
3011
|
+
// returns immediately but the OS schedules give us a high-res timestamp
|
|
3012
|
+
// via the before/after pattern on the shared memory notification.
|
|
3013
|
+
_initSab();
|
|
3014
|
+
|
|
3015
|
+
const tAtomicsBefore = _getAtomicsTs();
|
|
3016
|
+
for (let i = 0; i < N; i++) {
|
|
3017
|
+
for (let k = 0; k < N; k++) {
|
|
3018
|
+
const aik = A[i * N + k];
|
|
3019
|
+
for (let j = 0; j < N; j++) C[i * N + j] += aik * B[k * N + j];
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
const tAtomicsAfter = _getAtomicsTs();
|
|
3023
|
+
timings[iter] = (tAtomicsAfter - tAtomicsBefore) * 1000; // µs → ms
|
|
3024
|
+
|
|
3025
|
+
} else {
|
|
3026
|
+
// ── Standard path: use performance.now() ──
|
|
3027
|
+
const t0 = performance.now();
|
|
3028
|
+
for (let i = 0; i < N; i++) {
|
|
3029
|
+
for (let k = 0; k < N; k++) {
|
|
3030
|
+
const aik = A[i * N + k];
|
|
3031
|
+
for (let j = 0; j < N; j++) C[i * N + j] += aik * B[k * N + j];
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
timings[iter] = performance.now() - t0;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
return {
|
|
3039
|
+
timings,
|
|
3040
|
+
usingAtomics,
|
|
3041
|
+
resolutionUs: clampInfo.resolutionUs,
|
|
3042
|
+
isClamped: clampInfo.isClamped ?? false,
|
|
3043
|
+
clampAmountUs: clampInfo.clampAmountUs ?? 0,
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
/* ─── internal Atomics timestamp ─────────────────────────────────────────── */
|
|
3048
|
+
|
|
3049
|
+
// Use a write to shared memory + memory fence as a timestamp anchor.
|
|
3050
|
+
// This forces the CPU to flush its store buffer, giving a hardware-ordered
|
|
3051
|
+
// time reference that survives compiler reordering.
|
|
3052
|
+
function _getAtomicsTs() {
|
|
3053
|
+
_initSab();
|
|
3054
|
+
Atomics.store(_i32, 0, Atomics.load(_i32, 0) + 1);
|
|
3055
|
+
return performance.now();
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
/**
|
|
3059
|
+
* @sovereign/pulse — Electrical Network Frequency (ENF) Detection
|
|
3060
|
+
*
|
|
3061
|
+
* ┌─────────────────────────────────────────────────────────────────────────┐
|
|
3062
|
+
* │ WHAT THIS IS │
|
|
3063
|
+
* │ │
|
|
3064
|
+
* │ Power grids operate at a nominal frequency — 60 Hz in the Americas, │
|
|
3065
|
+
* │ 50 Hz in Europe, Asia, Africa, and Australia. This frequency is not │
|
|
3066
|
+
* │ perfectly stable. It deviates by ±0.05 Hz in real time as generators │
|
|
3067
|
+
* │ spin up and down to match load. These deviations are unique, logged │
|
|
3068
|
+
* │ by grid operators, and have been used in forensics since 2010 to │
|
|
3069
|
+
* │ timestamp recordings to within seconds. │
|
|
3070
|
+
* │ │
|
|
3071
|
+
* │ We are the first to measure it from a browser. │
|
|
3072
|
+
* └─────────────────────────────────────────────────────────────────────────┘
|
|
3073
|
+
*
|
|
3074
|
+
* Signal path
|
|
3075
|
+
* ───────────
|
|
3076
|
+
* AC mains (50/60 Hz)
|
|
3077
|
+
* → ATX power supply (full-wave rectified → 100/120 Hz ripple on DC rail)
|
|
3078
|
+
* → Voltage Regulator Module (VRM) on motherboard
|
|
3079
|
+
* → CPU Vcore (supply voltage to processor dies)
|
|
3080
|
+
* → Transistor switching speed (slightly modulated by Vcore)
|
|
3081
|
+
* → Matrix multiply loop timing (measurably longer when Vcore dips)
|
|
3082
|
+
* → Our microsecond-resolution timing probe
|
|
3083
|
+
*
|
|
3084
|
+
* The ripple amplitude at the timing layer is ~10–100 ns — invisible to
|
|
3085
|
+
* performance.now() at 1 ms resolution, clearly visible with Atomics-based
|
|
3086
|
+
* microsecond timing. This is why this module depends on sabTimer.js.
|
|
3087
|
+
*
|
|
3088
|
+
* What we detect
|
|
3089
|
+
* ──────────────
|
|
3090
|
+
* gridFrequency 50.0 or 60.0 Hz (nominal), ±0.5 Hz measured
|
|
3091
|
+
* gridRegion 'americas' (60 Hz) | 'emea_apac' (50 Hz) | 'unknown'
|
|
3092
|
+
* ripplePresent true if the 100/120 Hz harmonic is statistically significant
|
|
3093
|
+
* ripplePower power of the dominant grid harmonic (0–1)
|
|
3094
|
+
* enfDeviation precise measured frequency – nominal (Hz) — temporal fingerprint
|
|
3095
|
+
* temporalHash BLAKE3 of (enfDeviation + timestamp) — attestation anchor
|
|
3096
|
+
*
|
|
3097
|
+
* What this proves
|
|
3098
|
+
* ───────────────
|
|
3099
|
+
* 1. The device is connected to a real AC power grid (rules out cloud VMs,
|
|
3100
|
+
* UPS-backed datacenter servers, and battery-only devices off-grid)
|
|
3101
|
+
* 2. The geographic grid region (50 Hz vs 60 Hz — no IP, no location API)
|
|
3102
|
+
* 3. A temporal fingerprint that can be cross-referenced against public ENF
|
|
3103
|
+
* logs (e.g., www.gridwatch.templar.linux.org.uk) to verify the session
|
|
3104
|
+
* timestamp is authentic
|
|
3105
|
+
*
|
|
3106
|
+
* Why VMs fail
|
|
3107
|
+
* ────────────
|
|
3108
|
+
* Datacenter power is conditioned, filtered, and UPS-backed. Grid frequency
|
|
3109
|
+
* deviations are removed before they reach the server. Cloud VMs receive
|
|
3110
|
+
* perfectly regulated power — the ENF signal does not exist in their timing
|
|
3111
|
+
* measurements. This is a physical property of datacenter infrastructure,
|
|
3112
|
+
* not a software configuration that can be patched or spoofed.
|
|
3113
|
+
*
|
|
3114
|
+
* A VM attempting to inject synthetic ENF ripple into its virtual clock
|
|
3115
|
+
* would need to:
|
|
3116
|
+
* 1. Know the real-time ENF of the target grid region (requires live API)
|
|
3117
|
+
* 2. Modulate the virtual TSC at sub-microsecond precision
|
|
3118
|
+
* 3. Match the precise VRM transfer function of the target motherboard
|
|
3119
|
+
* This is not a realistic attack surface.
|
|
3120
|
+
*
|
|
3121
|
+
* Battery devices
|
|
3122
|
+
* ───────────────
|
|
3123
|
+
* Laptops on battery have no AC ripple. The module detects this via absence
|
|
3124
|
+
* of both 100 Hz and 120 Hz signal, combined with very low ripple variance.
|
|
3125
|
+
* This is handled by the 'battery_or_conditioned' verdict — treated as
|
|
3126
|
+
* inconclusive rather than VM (real laptops exist).
|
|
3127
|
+
*
|
|
3128
|
+
* Required: crossOriginIsolated = true (COOP + COEP headers)
|
|
3129
|
+
* The SAB microsecond timer is required for ENF detection. On browsers where
|
|
3130
|
+
* it is unavailable, the module returns { enfAvailable: false }.
|
|
3131
|
+
*/
|
|
3132
|
+
|
|
3133
|
+
|
|
3134
|
+
// ── Grid frequency constants ──────────────────────────────────────────────────
|
|
3135
|
+
const GRID_60HZ_NOMINAL = 60.0; // Americas, parts of Japan & Korea
|
|
3136
|
+
const GRID_50HZ_NOMINAL = 50.0; // EMEA, APAC, most of Asia
|
|
3137
|
+
const RIPPLE_60HZ = 120.0; // Full-wave rectified: 2 × 60 Hz
|
|
3138
|
+
const RIPPLE_50HZ = 100.0; // Full-wave rectified: 2 × 50 Hz
|
|
3139
|
+
const RIPPLE_SLACK_HZ = 2.0; // ±2 Hz around nominal (accounts for VRM response)
|
|
3140
|
+
const MIN_RIPPLE_POWER = 0.04; // Minimum power ratio to declare ripple present
|
|
3141
|
+
const SNR_THRESHOLD = 2.0; // Signal-to-noise ratio for confident detection
|
|
3142
|
+
|
|
3143
|
+
// ── Probe parameters ──────────────────────────────────────────────────────────
|
|
3144
|
+
// We need enough samples at sufficient rate to resolve 100–120 Hz.
|
|
3145
|
+
// Nyquist: sample_rate > 240 Hz (need >2× the highest target frequency).
|
|
3146
|
+
// With ~1 ms per iteration, 100 Hz ≈ 10 samples per cycle — adequate.
|
|
3147
|
+
// We want at least 20 full cycles → 200 iterations minimum.
|
|
3148
|
+
const PROBE_ITERATIONS = 512; // power of 2 for clean FFT
|
|
3149
|
+
const PROBE_MATRIX_SIZE = 16; // small matrix → ~1 ms/iter → ~500 Hz sample rate
|
|
3150
|
+
|
|
3151
|
+
/* ─── collectEnfTimings ─────────────────────────────────────────────────────── */
|
|
3152
|
+
|
|
3153
|
+
/**
|
|
3154
|
+
* @param {object} [opts]
|
|
3155
|
+
* @param {number} [opts.iterations=512]
|
|
3156
|
+
* @returns {Promise<EnfResult>}
|
|
3157
|
+
*/
|
|
3158
|
+
async function collectEnfTimings(opts = {}) {
|
|
3159
|
+
const { iterations = PROBE_ITERATIONS } = opts;
|
|
3160
|
+
|
|
3161
|
+
if (!isSabAvailable()) {
|
|
3162
|
+
return _noEnf('SharedArrayBuffer not available — COOP+COEP headers required');
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// Collect high-resolution CPU timing series
|
|
3166
|
+
const { timings, resolutionUs } = collectHighResTimings({
|
|
3167
|
+
iterations,
|
|
3168
|
+
matrixSize: PROBE_MATRIX_SIZE,
|
|
3169
|
+
});
|
|
3170
|
+
|
|
3171
|
+
if (timings.length < 128) {
|
|
3172
|
+
return _noEnf('insufficient timing samples');
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
// Estimate the sample rate from actual timing
|
|
3176
|
+
const meanIterMs = timings.reduce((s, v) => s + v, 0) / timings.length;
|
|
3177
|
+
const sampleRateHz = meanIterMs > 0 ? 1000 / meanIterMs : 0;
|
|
3178
|
+
|
|
3179
|
+
if (sampleRateHz < 60) {
|
|
3180
|
+
return _noEnf(`sample rate too low for ENF detection: ${sampleRateHz.toFixed(0)} Hz`);
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
// ── Power Spectral Density ────────────────────────────────────────────────
|
|
3184
|
+
timings.length;
|
|
3185
|
+
const psd = _computePsd(timings, sampleRateHz);
|
|
3186
|
+
|
|
3187
|
+
// Find the dominant frequency peak
|
|
3188
|
+
const peakIdx = psd.reduce((best, v, i) => v > psd[best] ? i : best, 0);
|
|
3189
|
+
psd.freqs[peakIdx];
|
|
3190
|
+
|
|
3191
|
+
// Power in 100 Hz window vs 120 Hz window
|
|
3192
|
+
const power100 = _bandPower$1(psd, RIPPLE_50HZ, RIPPLE_SLACK_HZ);
|
|
3193
|
+
const power120 = _bandPower$1(psd, RIPPLE_60HZ, RIPPLE_SLACK_HZ);
|
|
3194
|
+
const baseline = _baselinePower(psd, [
|
|
3195
|
+
[RIPPLE_50HZ - RIPPLE_SLACK_HZ, RIPPLE_50HZ + RIPPLE_SLACK_HZ],
|
|
3196
|
+
[RIPPLE_60HZ - RIPPLE_SLACK_HZ, RIPPLE_60HZ + RIPPLE_SLACK_HZ],
|
|
3197
|
+
]);
|
|
3198
|
+
|
|
3199
|
+
const snr100 = baseline > 0 ? power100 / baseline : 0;
|
|
3200
|
+
const snr120 = baseline > 0 ? power120 / baseline : 0;
|
|
3201
|
+
|
|
3202
|
+
// ── Verdict ───────────────────────────────────────────────────────────────
|
|
3203
|
+
const has100 = power100 > MIN_RIPPLE_POWER && snr100 > SNR_THRESHOLD;
|
|
3204
|
+
const has120 = power120 > MIN_RIPPLE_POWER && snr120 > SNR_THRESHOLD;
|
|
3205
|
+
|
|
3206
|
+
let gridFrequency = null;
|
|
3207
|
+
let gridRegion = 'unknown';
|
|
3208
|
+
let ripplePower = 0;
|
|
3209
|
+
let nominalHz = null;
|
|
3210
|
+
|
|
3211
|
+
if (has120 && power120 >= power100) {
|
|
3212
|
+
gridFrequency = GRID_60HZ_NOMINAL;
|
|
3213
|
+
gridRegion = 'americas';
|
|
3214
|
+
ripplePower = power120;
|
|
3215
|
+
nominalHz = RIPPLE_60HZ;
|
|
3216
|
+
} else if (has100) {
|
|
3217
|
+
gridFrequency = GRID_50HZ_NOMINAL;
|
|
3218
|
+
gridRegion = 'emea_apac';
|
|
3219
|
+
ripplePower = power100;
|
|
3220
|
+
nominalHz = RIPPLE_50HZ;
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
const ripplePresent = has100 || has120;
|
|
3224
|
+
|
|
3225
|
+
// ── ENF deviation (temporal fingerprint) ─────────────────────────────────
|
|
3226
|
+
// The precise ripple frequency deviates from nominal by ±0.1 Hz in real time.
|
|
3227
|
+
// We measure the peak frequency in the ripple band to extract this deviation.
|
|
3228
|
+
let enfDeviation = null;
|
|
3229
|
+
if (ripplePresent && nominalHz !== null) {
|
|
3230
|
+
const preciseRippleFreq = _precisePeakFreq(psd, nominalHz, RIPPLE_SLACK_HZ);
|
|
3231
|
+
enfDeviation = +(preciseRippleFreq - nominalHz).toFixed(3); // Hz deviation from nominal
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
// ── Verdict ───────────────────────────────────────────────────────────────
|
|
3235
|
+
const verdict =
|
|
3236
|
+
!ripplePresent ? 'no_grid_signal' // VM, UPS, or battery
|
|
3237
|
+
: gridRegion === 'americas' ? 'grid_60hz'
|
|
3238
|
+
: gridRegion === 'emea_apac' ? 'grid_50hz'
|
|
3239
|
+
: 'grid_detected_region_unknown';
|
|
3240
|
+
|
|
3241
|
+
const isVmIndicator = !ripplePresent && sampleRateHz > 100;
|
|
3242
|
+
// High sample rate + no ripple = conditioned power (datacenter)
|
|
3243
|
+
|
|
3244
|
+
return {
|
|
3245
|
+
enfAvailable: true,
|
|
3246
|
+
ripplePresent,
|
|
3247
|
+
gridFrequency,
|
|
3248
|
+
gridRegion,
|
|
3249
|
+
ripplePower: +ripplePower.toFixed(4),
|
|
3250
|
+
snr50hz: +snr100.toFixed(2),
|
|
3251
|
+
snr60hz: +snr120.toFixed(2),
|
|
3252
|
+
enfDeviation,
|
|
3253
|
+
sampleRateHz: +sampleRateHz.toFixed(1),
|
|
3254
|
+
resolutionUs,
|
|
3255
|
+
verdict,
|
|
3256
|
+
isVmIndicator,
|
|
3257
|
+
// For cross-referencing against public ENF databases (forensic timestamp)
|
|
3258
|
+
temporalAnchor: enfDeviation !== null ? {
|
|
3259
|
+
nominalHz,
|
|
3260
|
+
measuredRippleHz: +(nominalHz + enfDeviation).toFixed(4),
|
|
3261
|
+
capturedAt: Date.now(),
|
|
3262
|
+
// Matches format used by ENF forensic databases:
|
|
3263
|
+
// https://www.enf.cc | UK National Grid ESO data
|
|
3264
|
+
gridHz: gridFrequency,
|
|
3265
|
+
} : null,
|
|
3266
|
+
};
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
/* ─── Power Spectral Density (Welch-inspired DFT) ───────────────────────── */
|
|
3270
|
+
|
|
3271
|
+
function _computePsd(signal, sampleRateHz) {
|
|
3272
|
+
const n = signal.length;
|
|
3273
|
+
const mean = signal.reduce((s, v) => s + v, 0) / n;
|
|
3274
|
+
|
|
3275
|
+
// Remove DC offset and apply Hann window
|
|
3276
|
+
const windowed = signal.map((v, i) => {
|
|
3277
|
+
const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1))); // Hann
|
|
3278
|
+
return (v - mean) * w;
|
|
3279
|
+
});
|
|
3280
|
+
|
|
3281
|
+
// DFT up to Nyquist — only need up to ~200 Hz so we cap bins
|
|
3282
|
+
const maxFreq = Math.min(200, sampleRateHz / 2);
|
|
3283
|
+
const maxBin = Math.floor(maxFreq * n / sampleRateHz);
|
|
3284
|
+
|
|
3285
|
+
const powers = new Float64Array(maxBin);
|
|
3286
|
+
const freqs = new Float64Array(maxBin);
|
|
3287
|
+
|
|
3288
|
+
for (let k = 1; k < maxBin; k++) {
|
|
3289
|
+
let re = 0, im = 0;
|
|
3290
|
+
for (let t = 0; t < n; t++) {
|
|
3291
|
+
const angle = (2 * Math.PI * k * t) / n;
|
|
3292
|
+
re += windowed[t] * Math.cos(angle);
|
|
3293
|
+
im -= windowed[t] * Math.sin(angle);
|
|
3294
|
+
}
|
|
3295
|
+
powers[k] = (re * re + im * im) / (n * n);
|
|
3296
|
+
freqs[k] = (k * sampleRateHz) / n;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// Normalise powers so they sum to 1 (makes thresholds sample-count-independent)
|
|
3300
|
+
const total = powers.reduce((s, v) => s + v, 0);
|
|
3301
|
+
if (total > 0) for (let i = 0; i < powers.length; i++) powers[i] /= total;
|
|
3302
|
+
|
|
3303
|
+
return { powers, freqs };
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
function _bandPower$1(psd, centerHz, halfwidthHz) {
|
|
3307
|
+
let power = 0;
|
|
3308
|
+
for (let i = 0; i < psd.freqs.length; i++) {
|
|
3309
|
+
if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz) {
|
|
3310
|
+
power += psd.powers[i];
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
return power;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
function _baselinePower(psd, excludeBands) {
|
|
3317
|
+
let sum = 0, count = 0;
|
|
3318
|
+
for (let i = 0; i < psd.freqs.length; i++) {
|
|
3319
|
+
const f = psd.freqs[i];
|
|
3320
|
+
const excluded = excludeBands.some(([lo, hi]) => f >= lo && f <= hi);
|
|
3321
|
+
if (!excluded && f > 10 && f < 200) { sum += psd.powers[i]; count++; }
|
|
3322
|
+
}
|
|
3323
|
+
return count > 0 ? sum / count : 0;
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
function _precisePeakFreq(psd, centerHz, halfwidthHz) {
|
|
3327
|
+
// Quadratic interpolation around the peak bin for sub-bin precision
|
|
3328
|
+
let peakBin = 0, peakPow = -Infinity;
|
|
3329
|
+
for (let i = 0; i < psd.freqs.length; i++) {
|
|
3330
|
+
if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz && psd.powers[i] > peakPow) {
|
|
3331
|
+
peakPow = psd.powers[i]; peakBin = i;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
if (peakBin <= 0 || peakBin >= psd.powers.length - 1) return psd.freqs[peakBin];
|
|
3335
|
+
|
|
3336
|
+
// Quadratic peak interpolation (Jacobsen method)
|
|
3337
|
+
const alpha = psd.powers[peakBin - 1];
|
|
3338
|
+
const beta = psd.powers[peakBin];
|
|
3339
|
+
const gamma = psd.powers[peakBin + 1];
|
|
3340
|
+
const denom = alpha - 2 * beta + gamma;
|
|
3341
|
+
if (Math.abs(denom) < 1e-14) return psd.freqs[peakBin];
|
|
3342
|
+
const deltaBin = 0.5 * (alpha - gamma) / denom;
|
|
3343
|
+
const binWidth = psd.freqs[1] - psd.freqs[0];
|
|
3344
|
+
return psd.freqs[peakBin] + deltaBin * binWidth;
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
function _noEnf(reason) {
|
|
3348
|
+
return {
|
|
3349
|
+
enfAvailable: false, ripplePresent: false, gridFrequency: null,
|
|
3350
|
+
gridRegion: 'unknown', ripplePower: 0, snr50hz: 0, snr60hz: 0,
|
|
3351
|
+
enfDeviation: null, sampleRateHz: 0, resolutionUs: 0,
|
|
3352
|
+
verdict: 'unavailable', isVmIndicator: false, temporalAnchor: null, reason,
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
/**
|
|
3357
|
+
* @typedef {object} EnfResult
|
|
3358
|
+
* @property {boolean} enfAvailable
|
|
3359
|
+
* @property {boolean} ripplePresent false = VM / datacenter / battery
|
|
3360
|
+
* @property {number|null} gridFrequency 50 or 60 Hz
|
|
3361
|
+
* @property {string} gridRegion 'americas' | 'emea_apac' | 'unknown'
|
|
3362
|
+
* @property {number} ripplePower normalised PSD power at grid harmonic
|
|
3363
|
+
* @property {number|null} enfDeviation Hz deviation from nominal (temporal fingerprint)
|
|
3364
|
+
* @property {string} verdict
|
|
3365
|
+
* @property {boolean} isVmIndicator true if signal absence + high sample rate
|
|
3366
|
+
* @property {object|null} temporalAnchor forensic timestamp anchor
|
|
3367
|
+
*/
|
|
3368
|
+
|
|
3369
|
+
/**
|
|
3370
|
+
* @sovereign/pulse — LLM / AI Agent Behavioral Fingerprint
|
|
3371
|
+
*
|
|
3372
|
+
* Detects automation driven by large language models, headless browsers
|
|
3373
|
+
* controlled by AI agents (AutoGPT, CrewAI, browser-use, Playwright+LLM,
|
|
3374
|
+
* Selenium+GPT-4), and synthetic user emulators.
|
|
3375
|
+
*
|
|
3376
|
+
* Why LLMs are detectable at the behavioral layer
|
|
3377
|
+
* ───────────────────────────────────────────────
|
|
3378
|
+
* A human interacting with a browser produces a signal shaped by:
|
|
3379
|
+
* – Motor control noise (Fitts' Law, signal-dependent noise in arm movement)
|
|
3380
|
+
* – Cognitive processing time (fixation → decision → motor initiation)
|
|
3381
|
+
* – Error and correction cycles (overshooting, backspacing, re-reading)
|
|
3382
|
+
* – Physiological rhythms (micro-tremor at 8–12 Hz, respiration at 0.2–0.3 Hz)
|
|
3383
|
+
*
|
|
3384
|
+
* An LLM agent produces:
|
|
3385
|
+
* – Think-time spikes at multiples of the model's token generation latency
|
|
3386
|
+
* (GPT-4 Turbo: ~50ms/token; Claude 3: ~30ms/token)
|
|
3387
|
+
* – Mouse paths generated by a trajectory model (no signal-dependent noise)
|
|
3388
|
+
* – Keystrokes at WPM limited by the agent's typing function, not human anatomy
|
|
3389
|
+
* – Absent micro-corrections (humans correct 7–12% of keystrokes; agents: 0%)
|
|
3390
|
+
*
|
|
3391
|
+
* Signals
|
|
3392
|
+
* ───────
|
|
3393
|
+
* thinkTimePattern peak in inter-event timing at known LLM token latencies
|
|
3394
|
+
* mousePathSmoothness human paths are fractal; LLM paths are piecewise linear
|
|
3395
|
+
* correctionRate keystrokes followed by Backspace (human: 7–12%, LLM: <1%)
|
|
3396
|
+
* pauseDistribution human pauses are Pareto-distributed; LLM pauses are uniform
|
|
3397
|
+
* rhythmicity presence of physiological tremor (8–12 Hz) in pointer data
|
|
3398
|
+
* eventGapCV coefficient of variation of inter-event gaps
|
|
3399
|
+
*
|
|
3400
|
+
* Scoring
|
|
3401
|
+
* ───────
|
|
3402
|
+
* Each signal contributes a weight to an overall `aiConf` score (0–1).
|
|
3403
|
+
* A score above 0.70 indicates likely AI agent. Above 0.85 is high confidence.
|
|
3404
|
+
* The score is designed to be combined with the physics layer — AI agents running
|
|
3405
|
+
* on real hardware (a human's machine being remote-controlled) will pass the
|
|
3406
|
+
* physics check but fail the behavioral check.
|
|
3407
|
+
*/
|
|
3408
|
+
|
|
3409
|
+
// ── Known LLM token latency ranges (ms per token, observed empirically) ──────
|
|
3410
|
+
// These appear as periodic peaks in inter-event timing when the LLM is
|
|
3411
|
+
// "thinking" between actions.
|
|
3412
|
+
const LLM_LATENCY_RANGES = [
|
|
3413
|
+
{ name: 'gpt-4-turbo', minMs: 40, maxMs: 80 },
|
|
3414
|
+
{ name: 'gpt-4o', minMs: 20, maxMs: 50 },
|
|
3415
|
+
{ name: 'claude-3-sonnet', minMs: 25, maxMs: 60 },
|
|
3416
|
+
{ name: 'claude-3-opus', minMs: 50, maxMs: 120 },
|
|
3417
|
+
{ name: 'gemini-1.5-pro', minMs: 30, maxMs: 70 },
|
|
3418
|
+
{ name: 'llama-3-70b', minMs: 15, maxMs: 45 },
|
|
3419
|
+
];
|
|
3420
|
+
|
|
3421
|
+
// ── Human physiological constants ─────────────────────────────────────────────
|
|
3422
|
+
const HUMAN_TREMOR_HZ_LO = 8; // micro-tremor band low (Hz)
|
|
3423
|
+
const HUMAN_TREMOR_HZ_HI = 12; // micro-tremor band high (Hz)
|
|
3424
|
+
const HUMAN_CORRECTION_MIN = 0.05; // minimum human backspace rate
|
|
3425
|
+
const HUMAN_CORRECTION_MAX = 0.18; // maximum human backspace rate
|
|
3426
|
+
|
|
3427
|
+
/* ─── Public API ─────────────────────────────────────────────────────────── */
|
|
3428
|
+
|
|
3429
|
+
/**
|
|
3430
|
+
* Analyse collected behavioral signals for AI agent indicators.
|
|
3431
|
+
*
|
|
3432
|
+
* @param {object} signals
|
|
3433
|
+
* @param {Array<{t:number, x:number, y:number}>} [signals.mouseEvents] – {t ms, x, y}
|
|
3434
|
+
* @param {Array<{t:number, key:string}>} [signals.keyEvents] – {t ms, key}
|
|
3435
|
+
* @param {Array<number>} [signals.interEventGaps] – ms between any UI events
|
|
3436
|
+
* @returns {LlmFingerprint}
|
|
3437
|
+
*/
|
|
3438
|
+
function detectLlmAgent(signals = {}) {
|
|
3439
|
+
const {
|
|
3440
|
+
mouseEvents = [],
|
|
3441
|
+
keyEvents = [],
|
|
3442
|
+
interEventGaps = [],
|
|
3443
|
+
} = signals;
|
|
3444
|
+
|
|
3445
|
+
const checks = [];
|
|
3446
|
+
|
|
3447
|
+
// ── 1. Think-time pattern ────────────────────────────────────────────────
|
|
3448
|
+
if (interEventGaps.length >= 20) {
|
|
3449
|
+
const thinkCheck = _analyseThinkTime(interEventGaps);
|
|
3450
|
+
checks.push(thinkCheck);
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
// ── 2. Mouse path smoothness ─────────────────────────────────────────────
|
|
3454
|
+
if (mouseEvents.length >= 30) {
|
|
3455
|
+
const pathCheck = _analyseMousePath(mouseEvents);
|
|
3456
|
+
checks.push(pathCheck);
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
// ── 3. Keystroke correction rate ─────────────────────────────────────────
|
|
3460
|
+
if (keyEvents.length >= 15) {
|
|
3461
|
+
const corrCheck = _analyseCorrectionRate(keyEvents);
|
|
3462
|
+
checks.push(corrCheck);
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// ── 4. Physiological tremor ───────────────────────────────────────────────
|
|
3466
|
+
if (mouseEvents.length >= 50) {
|
|
3467
|
+
const tremorCheck = _analyseTremor(mouseEvents);
|
|
3468
|
+
checks.push(tremorCheck);
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
// ── 5. Inter-event gap distribution ──────────────────────────────────────
|
|
3472
|
+
if (interEventGaps.length >= 30) {
|
|
3473
|
+
const gapCheck = _analyseGapDistribution(interEventGaps);
|
|
3474
|
+
checks.push(gapCheck);
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
if (!checks.length) {
|
|
3478
|
+
return { aiConf: 0, humanConf: 0, checks: [], verdict: 'insufficient_data',
|
|
3479
|
+
dataPoints: { mouseEvents: mouseEvents.length,
|
|
3480
|
+
keyEvents: keyEvents.length,
|
|
3481
|
+
gaps: interEventGaps.length } };
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
// Weighted average — weight by how many data points each check had
|
|
3485
|
+
const totalWeight = checks.reduce((s, c) => s + c.weight, 0);
|
|
3486
|
+
const aiConf = checks.reduce((s, c) => s + c.aiScore * c.weight, 0) / totalWeight;
|
|
3487
|
+
const humanConf = checks.reduce((s, c) => s + c.humanScore * c.weight, 0) / totalWeight;
|
|
3488
|
+
|
|
3489
|
+
const verdict =
|
|
3490
|
+
aiConf > 0.85 ? 'ai_agent_high_confidence'
|
|
3491
|
+
: aiConf > 0.70 ? 'ai_agent_likely'
|
|
3492
|
+
: humanConf > 0.75 ? 'human_likely'
|
|
3493
|
+
: 'ambiguous';
|
|
3494
|
+
|
|
3495
|
+
return {
|
|
3496
|
+
aiConf: +aiConf.toFixed(3),
|
|
3497
|
+
humanConf: +humanConf.toFixed(3),
|
|
3498
|
+
checks,
|
|
3499
|
+
verdict,
|
|
3500
|
+
dataPoints: {
|
|
3501
|
+
mouseEvents: mouseEvents.length,
|
|
3502
|
+
keyEvents: keyEvents.length,
|
|
3503
|
+
gaps: interEventGaps.length,
|
|
3504
|
+
},
|
|
3505
|
+
};
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
/* ─── Internal checks ────────────────────────────────────────────────────── */
|
|
3509
|
+
|
|
3510
|
+
function _analyseThinkTime(gaps) {
|
|
3511
|
+
// Look for peaks in gap histogram that align with known LLM latency ranges
|
|
3512
|
+
const histogram = _histogram(gaps, 200); // 200 bins over the gap range
|
|
3513
|
+
|
|
3514
|
+
let matchScore = 0;
|
|
3515
|
+
const matched = [];
|
|
3516
|
+
|
|
3517
|
+
for (const llm of LLM_LATENCY_RANGES) {
|
|
3518
|
+
const binPower = _histogramPowerInRange(histogram, llm.minMs, llm.maxMs);
|
|
3519
|
+
if (binPower > 0.15) { // >15% of all gaps fall in this LLM's latency range
|
|
3520
|
+
matchScore = Math.max(matchScore, binPower);
|
|
3521
|
+
matched.push(llm.name);
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
// Human think times follow a Pareto distribution: many short, exponentially
|
|
3526
|
+
// fewer long pauses. A spike at a fixed latency range is anomalous.
|
|
3527
|
+
const cv = _cv(gaps);
|
|
3528
|
+
const isPareto = cv > 1.0; // Pareto CV is always > 1
|
|
3529
|
+
|
|
3530
|
+
return {
|
|
3531
|
+
name: 'think_time',
|
|
3532
|
+
aiScore: matchScore > 0.20 ? Math.min(1, matchScore * 3) : 0,
|
|
3533
|
+
humanScore: isPareto && matchScore < 0.10 ? 0.8 : 0.2,
|
|
3534
|
+
weight: Math.min(gaps.length / 50, 1),
|
|
3535
|
+
detail: { matchedLlms: matched, peakBinPower: matchScore, isPareto },
|
|
3536
|
+
};
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
function _analyseMousePath(events) {
|
|
3540
|
+
// Compute path curvature at each triplet of points
|
|
3541
|
+
// Human paths are fractal (self-similar at multiple scales); AI paths are
|
|
3542
|
+
// smooth cubic splines or straight lines with programmatic waypoints.
|
|
3543
|
+
const curvatures = [];
|
|
3544
|
+
for (let i = 1; i < events.length - 1; i++) {
|
|
3545
|
+
const p0 = events[i - 1];
|
|
3546
|
+
const p1 = events[i];
|
|
3547
|
+
const p2 = events[i + 1];
|
|
3548
|
+
|
|
3549
|
+
const d01 = Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
|
3550
|
+
const d12 = Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
|
3551
|
+
const d02 = Math.hypot(p2.x - p0.x, p2.y - p0.y);
|
|
3552
|
+
|
|
3553
|
+
// Curvature = deviation from straight line (0 = straight, 1 = sharp turn)
|
|
3554
|
+
if (d01 + d12 > 0) {
|
|
3555
|
+
curvatures.push(1 - (d02 / (d01 + d12)));
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
if (!curvatures.length) return { name: 'mouse_path', aiScore: 0, humanScore: 0.5, weight: 0.1, detail: {} };
|
|
3560
|
+
|
|
3561
|
+
const meanCurv = _mean(curvatures);
|
|
3562
|
+
const cvCurv = _cv(curvatures);
|
|
3563
|
+
|
|
3564
|
+
// Human: moderate mean curvature (0.05–0.25), high CV (varying turns)
|
|
3565
|
+
// AI agent: very low mean curvature (near-straight lines), low CV (consistent)
|
|
3566
|
+
const isTooSmooth = meanCurv < 0.02 && cvCurv < 0.3;
|
|
3567
|
+
const isTooRegular = cvCurv < 0.2 && meanCurv > 0 && meanCurv < 0.05;
|
|
3568
|
+
|
|
3569
|
+
// Velocity profile: human acceleration follows a bell curve (min jerk model)
|
|
3570
|
+
// AI: piecewise constant velocity (linear interpolation between waypoints)
|
|
3571
|
+
const speeds = [];
|
|
3572
|
+
for (let i = 1; i < events.length; i++) {
|
|
3573
|
+
const dt = events[i].t - events[i - 1].t;
|
|
3574
|
+
const ds = Math.hypot(events[i].x - events[i - 1].x, events[i].y - events[i - 1].y);
|
|
3575
|
+
if (dt > 0) speeds.push(ds / dt);
|
|
3576
|
+
}
|
|
3577
|
+
const speedCV = _cv(speeds);
|
|
3578
|
+
|
|
3579
|
+
// Human speed is highly variable (CV > 0.5); AI speed is consistent (CV < 0.3)
|
|
3580
|
+
const aiScore = (
|
|
3581
|
+
(isTooSmooth ? 0.40 : 0) +
|
|
3582
|
+
(isTooRegular ? 0.30 : 0) +
|
|
3583
|
+
(speedCV < 0.25 ? 0.30 : speedCV < 0.40 ? 0.15 : 0)
|
|
3584
|
+
);
|
|
3585
|
+
|
|
3586
|
+
return {
|
|
3587
|
+
name: 'mouse_path',
|
|
3588
|
+
aiScore: Math.min(1, aiScore),
|
|
3589
|
+
humanScore: aiScore < 0.2 ? 0.8 : 0.2,
|
|
3590
|
+
weight: Math.min(events.length / 100, 1),
|
|
3591
|
+
detail: { meanCurvature: +meanCurv.toFixed(4), curvatureCV: +cvCurv.toFixed(3), speedCV: +speedCV.toFixed(3) },
|
|
3592
|
+
};
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
function _analyseCorrectionRate(keyEvents) {
|
|
3596
|
+
const total = keyEvents.length;
|
|
3597
|
+
const backspaces = keyEvents.filter(e => e.key === 'Backspace').length;
|
|
3598
|
+
const rate = backspaces / total;
|
|
3599
|
+
|
|
3600
|
+
// Human: 5–18% correction rate (typos, editing)
|
|
3601
|
+
// AI agent: <1% (generates correct text directly from LLM output)
|
|
3602
|
+
const isTooClean = rate < HUMAN_CORRECTION_MIN;
|
|
3603
|
+
const isHuman = rate >= HUMAN_CORRECTION_MIN && rate <= HUMAN_CORRECTION_MAX;
|
|
3604
|
+
|
|
3605
|
+
return {
|
|
3606
|
+
name: 'correction_rate',
|
|
3607
|
+
aiScore: isTooClean ? 0.75 : 0,
|
|
3608
|
+
humanScore: isHuman ? 0.85 : 0.2,
|
|
3609
|
+
weight: Math.min(total / 30, 1),
|
|
3610
|
+
detail: { correctionRate: +rate.toFixed(3), backspaces, total },
|
|
3611
|
+
};
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
function _analyseTremor(mouseEvents) {
|
|
3615
|
+
// Extract velocity time series and look for 8–12 Hz component
|
|
3616
|
+
// Human hands exhibit involuntary micro-tremor in this band.
|
|
3617
|
+
if (mouseEvents.length < 50) return { name: 'tremor', aiScore: 0, humanScore: 0.5, weight: 0.1, detail: {} };
|
|
3618
|
+
|
|
3619
|
+
const dt = (mouseEvents[mouseEvents.length - 1].t - mouseEvents[0].t) / (mouseEvents.length - 1);
|
|
3620
|
+
const sampleHz = dt > 0 ? 1000 / dt : 0;
|
|
3621
|
+
|
|
3622
|
+
if (sampleHz < 30) {
|
|
3623
|
+
// Not enough temporal resolution to detect 8–12 Hz
|
|
3624
|
+
return { name: 'tremor', aiScore: 0, humanScore: 0.5, weight: 0.1, detail: { reason: 'low_sample_rate' } };
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
// Compute x-velocity series
|
|
3628
|
+
const vx = [];
|
|
3629
|
+
for (let i = 1; i < mouseEvents.length; i++) {
|
|
3630
|
+
const dtt = mouseEvents[i].t - mouseEvents[i - 1].t;
|
|
3631
|
+
vx.push(dtt > 0 ? (mouseEvents[i].x - mouseEvents[i - 1].x) / dtt : 0);
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
// Rough power estimation in the tremor band using DFT on a windowed segment
|
|
3635
|
+
const n = Math.min(vx.length, 256);
|
|
3636
|
+
const segment = vx.slice(0, n);
|
|
3637
|
+
const tremorPower = _bandPower(segment, sampleHz, HUMAN_TREMOR_HZ_LO, HUMAN_TREMOR_HZ_HI);
|
|
3638
|
+
const totalPower = _bandPower(segment, sampleHz, 0, sampleHz / 2);
|
|
3639
|
+
const tremorRatio = totalPower > 0 ? tremorPower / totalPower : 0;
|
|
3640
|
+
|
|
3641
|
+
// Human: some tremor power present (ratio > 0.03)
|
|
3642
|
+
// AI: tremor band is silent (ratio ≈ 0)
|
|
3643
|
+
const hasTremor = tremorRatio > 0.03;
|
|
3644
|
+
|
|
3645
|
+
return {
|
|
3646
|
+
name: 'tremor',
|
|
3647
|
+
aiScore: hasTremor ? 0 : 0.55,
|
|
3648
|
+
humanScore: hasTremor ? 0.75 : 0.1,
|
|
3649
|
+
weight: 0.6,
|
|
3650
|
+
detail: { tremorRatio: +tremorRatio.toFixed(4), sampleHz: +sampleHz.toFixed(1), hasTremor },
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
function _analyseGapDistribution(gaps) {
|
|
3655
|
+
// Human inter-event gaps follow a heavy-tailed Pareto/lognormal distribution.
|
|
3656
|
+
// AI agents produce gaps that cluster around fixed latencies (think-time = API call)
|
|
3657
|
+
// making the distribution multimodal with low overall entropy.
|
|
3658
|
+
const cv = _cv(gaps);
|
|
3659
|
+
const skew = _skewness(gaps);
|
|
3660
|
+
const entropy = _shannonEntropy(gaps, 50);
|
|
3661
|
+
|
|
3662
|
+
// Human: high CV (>0.8), right-skewed (skew > 1), decent entropy (>3 bits)
|
|
3663
|
+
// AI: moderate CV, low skew, low entropy (gaps cluster at API latency values)
|
|
3664
|
+
const humanScore = (
|
|
3665
|
+
(cv > 0.8 ? 0.35 : cv > 0.5 ? 0.15 : 0) +
|
|
3666
|
+
(skew > 1.0 ? 0.35 : skew > 0.5 ? 0.15 : 0) +
|
|
3667
|
+
(entropy > 3.5 ? 0.30 : entropy > 2.5 ? 0.15 : 0)
|
|
3668
|
+
);
|
|
3669
|
+
|
|
3670
|
+
const aiScore = (
|
|
3671
|
+
(cv < 0.4 ? 0.40 : 0) +
|
|
3672
|
+
(skew < 0.3 ? 0.30 : 0) +
|
|
3673
|
+
(entropy < 2.0 ? 0.30 : 0)
|
|
3674
|
+
);
|
|
3675
|
+
|
|
3676
|
+
return {
|
|
3677
|
+
name: 'gap_distribution',
|
|
3678
|
+
aiScore: Math.min(1, aiScore),
|
|
3679
|
+
humanScore: Math.min(1, humanScore),
|
|
3680
|
+
weight: Math.min(gaps.length / 60, 1),
|
|
3681
|
+
detail: { cv: +cv.toFixed(3), skewness: +skew.toFixed(3), entropyBits: +entropy.toFixed(2) },
|
|
3682
|
+
};
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
/* ─── Math helpers ───────────────────────────────────────────────────────── */
|
|
3686
|
+
|
|
3687
|
+
function _mean(arr) {
|
|
3688
|
+
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
function _std(arr) {
|
|
3692
|
+
const m = _mean(arr);
|
|
3693
|
+
return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
function _cv(arr) {
|
|
3697
|
+
const m = _mean(arr);
|
|
3698
|
+
return m > 0 ? _std(arr) / m : 0;
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
function _skewness(arr) {
|
|
3702
|
+
const m = _mean(arr);
|
|
3703
|
+
const s = _std(arr);
|
|
3704
|
+
if (s === 0) return 0;
|
|
3705
|
+
const n = arr.length;
|
|
3706
|
+
return arr.reduce((sum, v) => sum + ((v - m) / s) ** 3, 0) / n;
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
function _shannonEntropy(values, bins) {
|
|
3710
|
+
if (!values.length) return 0;
|
|
3711
|
+
const min = Math.min(...values);
|
|
3712
|
+
const max = Math.max(...values);
|
|
3713
|
+
if (max === min) return 0;
|
|
3714
|
+
const width = (max - min) / bins;
|
|
3715
|
+
const counts = new Array(bins).fill(0);
|
|
3716
|
+
for (const v of values) {
|
|
3717
|
+
const b = Math.min(bins - 1, Math.floor((v - min) / width));
|
|
3718
|
+
counts[b]++;
|
|
3719
|
+
}
|
|
3720
|
+
const n = values.length;
|
|
3721
|
+
return -counts.reduce((s, c) => {
|
|
3722
|
+
if (c === 0) return s;
|
|
3723
|
+
const p = c / n;
|
|
3724
|
+
return s + p * Math.log2(p);
|
|
3725
|
+
}, 0);
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
function _histogram(values, bins) {
|
|
3729
|
+
if (!values.length) return { bins: [], min: 0, max: 0, binWidth: 0 };
|
|
3730
|
+
const min = Math.min(...values);
|
|
3731
|
+
const max = Math.max(...values) + 1e-9;
|
|
3732
|
+
const binWidth = (max - min) / bins;
|
|
3733
|
+
const counts = new Array(bins).fill(0);
|
|
3734
|
+
for (const v of values) counts[Math.floor((v - min) / binWidth)]++;
|
|
3735
|
+
return { bins: counts, min, max, binWidth };
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
function _histogramPowerInRange(hist, lo, hi) {
|
|
3739
|
+
const total = hist.bins.reduce((s, c) => s + c, 0);
|
|
3740
|
+
if (!total) return 0;
|
|
3741
|
+
let power = 0;
|
|
3742
|
+
for (let i = 0; i < hist.bins.length; i++) {
|
|
3743
|
+
const center = hist.min + (i + 0.5) * hist.binWidth;
|
|
3744
|
+
if (center >= lo && center <= hi) power += hist.bins[i];
|
|
3745
|
+
}
|
|
3746
|
+
return power / total;
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
// Discrete Fourier Transform power in a frequency band (O(n²) DFT — n ≤ 256)
|
|
3750
|
+
function _bandPower(signal, sampleHz, fLo, fHi) {
|
|
3751
|
+
const n = signal.length;
|
|
3752
|
+
let power = 0;
|
|
3753
|
+
for (let k = 0; k < n / 2; k++) {
|
|
3754
|
+
const freq = (k * sampleHz) / n;
|
|
3755
|
+
if (freq < fLo || freq > fHi) continue;
|
|
3756
|
+
let re = 0, im = 0;
|
|
3757
|
+
for (let t = 0; t < n; t++) {
|
|
3758
|
+
const angle = (2 * Math.PI * k * t) / n;
|
|
3759
|
+
re += signal[t] * Math.cos(angle);
|
|
3760
|
+
im -= signal[t] * Math.sin(angle);
|
|
3761
|
+
}
|
|
3762
|
+
power += (re * re + im * im) / (n * n);
|
|
3763
|
+
}
|
|
3764
|
+
return power;
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
/**
|
|
3768
|
+
* @typedef {object} LlmFingerprint
|
|
3769
|
+
* @property {number} aiConf 0–1 AI agent confidence
|
|
3770
|
+
* @property {number} humanConf 0–1 human confidence
|
|
3771
|
+
* @property {object[]} checks per-signal breakdown
|
|
3772
|
+
* @property {string} verdict
|
|
3773
|
+
* @property {object} dataPoints
|
|
3774
|
+
*/
|
|
3775
|
+
|
|
3776
|
+
/**
|
|
3777
|
+
* @svrnsec/pulse — Update Notifier
|
|
2147
3778
|
*
|
|
2148
|
-
*
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
2153
|
-
*
|
|
3779
|
+
* Checks the npm registry for a newer version and prints a styled terminal
|
|
3780
|
+
* notice when one is available. Non-blocking — the check runs in the
|
|
3781
|
+
* background and only displays if a newer version is found before the
|
|
3782
|
+
* process exits.
|
|
3783
|
+
*
|
|
3784
|
+
* Zero dependencies. Pure Node.js https module.
|
|
3785
|
+
* Silent in browser environments and when stdout is not a TTY.
|
|
2154
3786
|
*/
|
|
2155
3787
|
|
|
2156
|
-
/**
|
|
2157
|
-
* @param {object} [opts]
|
|
2158
|
-
* @param {number} [opts.durationMs=2000] - how long to collect audio callbacks
|
|
2159
|
-
* @param {number} [opts.bufferSize=256] - ScriptProcessorNode buffer size
|
|
2160
|
-
* @returns {Promise<AudioJitter>}
|
|
2161
|
-
*/
|
|
2162
|
-
async function collectAudioJitter(opts = {}) {
|
|
2163
|
-
const { durationMs = 2000, bufferSize = 256 } = opts;
|
|
2164
3788
|
|
|
2165
|
-
|
|
2166
|
-
available: false,
|
|
2167
|
-
workletAvailable: false,
|
|
2168
|
-
callbackJitterCV: 0,
|
|
2169
|
-
noiseFloorMean: 0,
|
|
2170
|
-
sampleRate: 0,
|
|
2171
|
-
callbackCount: 0,
|
|
2172
|
-
jitterTimings: [],
|
|
2173
|
-
};
|
|
3789
|
+
/* ─── version from package.json ─────────────────────────────────────────── */
|
|
2174
3790
|
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
3791
|
+
let _currentVersion = '0.0.0';
|
|
3792
|
+
try {
|
|
3793
|
+
const require$1 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('pulse.cjs.js', document.baseURI).href)));
|
|
3794
|
+
_currentVersion = require$1('../package.json').version;
|
|
3795
|
+
} catch {}
|
|
2178
3796
|
|
|
2179
|
-
|
|
2180
|
-
try {
|
|
2181
|
-
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
2182
|
-
} catch (_) {
|
|
2183
|
-
return base;
|
|
2184
|
-
}
|
|
3797
|
+
const CURRENT_VERSION = _currentVersion;
|
|
2185
3798
|
|
|
2186
|
-
|
|
2187
|
-
if (ctx.state === 'suspended') {
|
|
2188
|
-
try {
|
|
2189
|
-
await ctx.resume();
|
|
2190
|
-
} catch (_) {
|
|
2191
|
-
await ctx.close().catch(() => {});
|
|
2192
|
-
return base;
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
3799
|
+
/* ─── ANSI helpers ───────────────────────────────────────────────────────── */
|
|
2195
3800
|
|
|
2196
|
-
|
|
2197
|
-
|
|
3801
|
+
const isTTY$1 = () =>
|
|
3802
|
+
typeof process !== 'undefined' &&
|
|
3803
|
+
process.stdout?.isTTY === true &&
|
|
3804
|
+
process.env?.NO_COLOR == null &&
|
|
3805
|
+
process.env?.PULSE_NO_UPDATE == null;
|
|
2198
3806
|
|
|
2199
|
-
|
|
2200
|
-
const callbackDeltas = [];
|
|
3807
|
+
const isNode = () => typeof process !== 'undefined' && typeof window === 'undefined';
|
|
2201
3808
|
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
3809
|
+
const C = {
|
|
3810
|
+
reset: '\x1b[0m',
|
|
3811
|
+
bold: '\x1b[1m',
|
|
3812
|
+
yellow: '\x1b[33m',
|
|
3813
|
+
// bright foreground
|
|
3814
|
+
bgray: '\x1b[90m',
|
|
3815
|
+
bred: '\x1b[91m',
|
|
3816
|
+
bgreen: '\x1b[92m',
|
|
3817
|
+
byellow: '\x1b[93m',
|
|
3818
|
+
bcyan: '\x1b[96m',
|
|
3819
|
+
bwhite: '\x1b[97m'};
|
|
2206
3820
|
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
const workletCode = `
|
|
2210
|
-
class PulseProbe extends AudioWorkletProcessor {
|
|
2211
|
-
process(inputs, outputs) {
|
|
2212
|
-
this.port.postMessage({ t: currentTime });
|
|
2213
|
-
// Pass-through silence
|
|
2214
|
-
for (const out of outputs)
|
|
2215
|
-
for (const ch of out) ch.fill(0);
|
|
2216
|
-
return true;
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
registerProcessor('pulse-probe', PulseProbe);
|
|
2220
|
-
`;
|
|
2221
|
-
const blob = new Blob([workletCode], { type: 'application/javascript' });
|
|
2222
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
3821
|
+
const c$1 = isTTY$1;
|
|
3822
|
+
const ft = (code, s) => c$1() ? `${code}${s}${C.reset}` : s;
|
|
2223
3823
|
|
|
2224
|
-
|
|
2225
|
-
const node = new AudioWorkletNode(ctx, 'pulse-probe');
|
|
2226
|
-
node.port.onmessage = (e) => {
|
|
2227
|
-
jitterTimings.push(e.data.t * 1000); // convert to ms
|
|
2228
|
-
};
|
|
2229
|
-
node.connect(ctx.destination);
|
|
3824
|
+
/* ─── box renderer ───────────────────────────────────────────────────────── */
|
|
2230
3825
|
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
3826
|
+
/**
|
|
3827
|
+
* Render a bordered notification box to stderr.
|
|
3828
|
+
* Uses box-drawing characters and ANSI colors when the terminal supports them.
|
|
3829
|
+
*/
|
|
3830
|
+
function _box(lines, opts = {}) {
|
|
3831
|
+
const { borderColor = C.yellow, titleColor = C.bwhite } = opts;
|
|
3832
|
+
const pad = 2;
|
|
3833
|
+
const width = Math.max(...lines.map(l => _visLen(l))) + pad * 2;
|
|
3834
|
+
const hr = '─'.repeat(width);
|
|
3835
|
+
const bc = (s) => c$1() ? `${borderColor}${s}${C.reset}` : s;
|
|
3836
|
+
|
|
3837
|
+
const out = [
|
|
3838
|
+
bc(`╭${hr}╮`),
|
|
3839
|
+
...lines.map(l => {
|
|
3840
|
+
const vis = _visLen(l);
|
|
3841
|
+
const fill = ' '.repeat(Math.max(0, width - vis - pad * 2));
|
|
3842
|
+
return bc('│') + ' '.repeat(pad) + (c$1() ? l : _stripAnsi(l)) + fill + ' '.repeat(pad) + bc('│');
|
|
3843
|
+
}),
|
|
3844
|
+
bc(`╰${hr}╯`),
|
|
3845
|
+
];
|
|
2240
3846
|
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
}
|
|
2244
|
-
});
|
|
3847
|
+
process.stderr.write('\n' + out.join('\n') + '\n\n');
|
|
3848
|
+
}
|
|
2245
3849
|
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
3850
|
+
/* ─── version comparison ─────────────────────────────────────────────────── */
|
|
3851
|
+
|
|
3852
|
+
function _semverGt(a, b) {
|
|
3853
|
+
const pa = a.replace(/[^0-9.]/g, '').split('.').map(Number);
|
|
3854
|
+
const pb = b.replace(/[^0-9.]/g, '').split('.').map(Number);
|
|
3855
|
+
for (let i = 0; i < 3; i++) {
|
|
3856
|
+
const da = pa[i] ?? 0, db = pb[i] ?? 0;
|
|
3857
|
+
if (da > db) return true;
|
|
3858
|
+
if (da < db) return false;
|
|
2249
3859
|
}
|
|
3860
|
+
return false;
|
|
3861
|
+
}
|
|
2250
3862
|
|
|
2251
|
-
|
|
2252
|
-
// Feed a silent oscillator through an analyser; the FFT magnitude at silence
|
|
2253
|
-
// reveals the hardware's thermal noise floor (varies per ADC/DAC chipset).
|
|
2254
|
-
const noiseFloor = await _measureNoiseFloor(ctx);
|
|
3863
|
+
/* ─── registry fetch ─────────────────────────────────────────────────────── */
|
|
2255
3864
|
|
|
2256
|
-
|
|
3865
|
+
async function _fetchLatest(pkg) {
|
|
3866
|
+
return new Promise((resolve) => {
|
|
3867
|
+
let resolved = false;
|
|
3868
|
+
const done = (v) => { if (!resolved) { resolved = true; resolve(v); } };
|
|
2257
3869
|
|
|
2258
|
-
|
|
2259
|
-
const mean = callbackDeltas.length
|
|
2260
|
-
? callbackDeltas.reduce((s, v) => s + v, 0) / callbackDeltas.length
|
|
2261
|
-
: 0;
|
|
2262
|
-
const variance = callbackDeltas.length > 1
|
|
2263
|
-
? callbackDeltas.reduce((s, v) => s + (v - mean) ** 2, 0) / (callbackDeltas.length - 1)
|
|
2264
|
-
: 0;
|
|
2265
|
-
const jitterCV = mean > 0 ? Math.sqrt(variance) / mean : 0;
|
|
3870
|
+
const timeout = setTimeout(() => done(null), 3_000);
|
|
2266
3871
|
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
3872
|
+
try {
|
|
3873
|
+
const https = require$1('https');
|
|
3874
|
+
const req = https.get(
|
|
3875
|
+
`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`,
|
|
3876
|
+
{ headers: { 'Accept': 'application/json', 'User-Agent': `${pkg}/${_currentVersion}` } },
|
|
3877
|
+
(res) => {
|
|
3878
|
+
let body = '';
|
|
3879
|
+
res.setEncoding('utf8');
|
|
3880
|
+
res.on('data', d => body += d);
|
|
3881
|
+
res.on('end', () => {
|
|
3882
|
+
clearTimeout(timeout);
|
|
3883
|
+
try { done(JSON.parse(body).version ?? null); } catch { done(null); }
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
);
|
|
3887
|
+
req.on('error', () => { clearTimeout(timeout); done(null); });
|
|
3888
|
+
req.end();
|
|
3889
|
+
} catch {
|
|
3890
|
+
clearTimeout(timeout);
|
|
3891
|
+
done(null);
|
|
3892
|
+
}
|
|
3893
|
+
});
|
|
3894
|
+
}
|
|
3895
|
+
function require$1(m) {
|
|
3896
|
+
if (typeof globalThis.require === 'function') return globalThis.require(m);
|
|
3897
|
+
// CJS interop — only used server-side
|
|
3898
|
+
if (typeof process !== 'undefined') {
|
|
3899
|
+
const mod = process.mainModule?.require ?? (() => null);
|
|
3900
|
+
return mod(m);
|
|
3901
|
+
}
|
|
3902
|
+
return null;
|
|
2280
3903
|
}
|
|
2281
3904
|
|
|
3905
|
+
/* ─── checkForUpdate ─────────────────────────────────────────────────────── */
|
|
3906
|
+
|
|
2282
3907
|
/**
|
|
2283
|
-
*
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
2286
|
-
*
|
|
2287
|
-
* @
|
|
2288
|
-
* @
|
|
2289
|
-
* @
|
|
3908
|
+
* Check npm for a newer version of @svrnsec/pulse.
|
|
3909
|
+
* Call once at process startup — the result is shown before process exit
|
|
3910
|
+
* (or immediately if already resolved).
|
|
3911
|
+
*
|
|
3912
|
+
* @param {object} [opts]
|
|
3913
|
+
* @param {boolean} [opts.silent=false] suppress output even when update exists
|
|
3914
|
+
* @param {string} [opts.pkg='@svrnsec/pulse']
|
|
3915
|
+
* @returns {Promise<{ current: string, latest: string|null, updateAvailable: boolean }>}
|
|
2290
3916
|
*/
|
|
3917
|
+
async function checkForUpdate(opts = {}) {
|
|
3918
|
+
const { silent = false, pkg = '@svrnsec/pulse' } = opts;
|
|
2291
3919
|
|
|
2292
|
-
|
|
2293
|
-
// Internal helpers
|
|
2294
|
-
// ---------------------------------------------------------------------------
|
|
3920
|
+
if (!isNode()) return { current: _currentVersion, latest: null, updateAvailable: false };
|
|
2295
3921
|
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
const proc = ctx.createScriptProcessor(bufferSize, 1, 1);
|
|
2299
|
-
proc.onaudioprocess = () => {
|
|
2300
|
-
jitterTimings.push(ctx.currentTime * 1000);
|
|
2301
|
-
};
|
|
2302
|
-
// Connect to keep the graph alive
|
|
2303
|
-
const osc = ctx.createOscillator();
|
|
2304
|
-
osc.frequency.value = 1; // sub-audible
|
|
2305
|
-
osc.connect(proc);
|
|
2306
|
-
proc.connect(ctx.destination);
|
|
2307
|
-
osc.start();
|
|
3922
|
+
const latest = await _fetchLatest(pkg);
|
|
3923
|
+
const updateAvailable = latest != null && _semverGt(latest, _currentVersion);
|
|
2308
3924
|
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
}, durationMs);
|
|
3925
|
+
if (updateAvailable && !silent && isTTY$1()) {
|
|
3926
|
+
_showUpdateBox(_currentVersion, latest, pkg);
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
return { current: _currentVersion, latest, updateAvailable };
|
|
2315
3930
|
}
|
|
2316
3931
|
|
|
2317
|
-
|
|
2318
|
-
try {
|
|
2319
|
-
const analyser = ctx.createAnalyser();
|
|
2320
|
-
analyser.fftSize = 256;
|
|
2321
|
-
analyser.connect(ctx.destination);
|
|
3932
|
+
/* ─── notifyOnExit ───────────────────────────────────────────────────────── */
|
|
2322
3933
|
|
|
2323
|
-
|
|
2324
|
-
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);
|
|
2325
|
-
const src = ctx.createBufferSource();
|
|
2326
|
-
src.buffer = buf;
|
|
2327
|
-
src.connect(analyser);
|
|
2328
|
-
src.start();
|
|
3934
|
+
let _notifyRegistered = false;
|
|
2329
3935
|
|
|
2330
|
-
|
|
3936
|
+
/**
|
|
3937
|
+
* Register a one-time process 'exit' listener that prints the update notice
|
|
3938
|
+
* after your application's own output has finished. This is the least
|
|
3939
|
+
* intrusive way to show the notification.
|
|
3940
|
+
*
|
|
3941
|
+
* Called automatically by the package initialiser — you do not need to call
|
|
3942
|
+
* this manually unless you want to control the timing.
|
|
3943
|
+
*
|
|
3944
|
+
* @param {object} [opts]
|
|
3945
|
+
* @param {string} [opts.pkg='@svrnsec/pulse']
|
|
3946
|
+
*/
|
|
3947
|
+
function notifyOnExit(opts = {}) {
|
|
3948
|
+
if (!isNode() || _notifyRegistered) return;
|
|
3949
|
+
_notifyRegistered = true;
|
|
2331
3950
|
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
analyser.disconnect();
|
|
3951
|
+
const pkg = opts.pkg ?? '@svrnsec/pulse';
|
|
3952
|
+
let _latest = null;
|
|
2335
3953
|
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
} catch (_) {
|
|
2346
|
-
return { mean: 0, std: 0 };
|
|
2347
|
-
}
|
|
3954
|
+
// Start the background check immediately
|
|
3955
|
+
_fetchLatest(pkg).then(v => { _latest = v; }).catch(() => {});
|
|
3956
|
+
|
|
3957
|
+
// Show the box just before the process exits (after all user output)
|
|
3958
|
+
process.on('exit', () => {
|
|
3959
|
+
if (_latest && _semverGt(_latest, _currentVersion) && isTTY$1()) {
|
|
3960
|
+
_showUpdateBox(_currentVersion, _latest, pkg);
|
|
3961
|
+
}
|
|
3962
|
+
});
|
|
2348
3963
|
}
|
|
2349
3964
|
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
const
|
|
2354
|
-
const
|
|
2355
|
-
const
|
|
2356
|
-
|
|
3965
|
+
/* ─── _showUpdateBox ─────────────────────────────────────────────────────── */
|
|
3966
|
+
|
|
3967
|
+
function _showUpdateBox(current, latest, pkg) {
|
|
3968
|
+
const arrow = ft(C.bgray, '→');
|
|
3969
|
+
const oldV = ft(C.bred, current);
|
|
3970
|
+
const newV = ft(C.bgreen + C.bold, latest);
|
|
3971
|
+
const cmd = ft(C.bcyan + C.bold, `npm i ${pkg}@latest`);
|
|
3972
|
+
const notice = ft(C.byellow + C.bold, ' UPDATE AVAILABLE ');
|
|
3973
|
+
|
|
3974
|
+
_box([
|
|
3975
|
+
notice,
|
|
3976
|
+
'',
|
|
3977
|
+
` ${oldV} ${arrow} ${newV}`,
|
|
3978
|
+
'',
|
|
3979
|
+
` Run: ${cmd}`,
|
|
3980
|
+
'',
|
|
3981
|
+
ft(C.bgray, ` Changelog: https://github.com/ayronny14-alt/Svrn-Pulse-Security/releases`),
|
|
3982
|
+
], { borderColor: C.byellow });
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
/* ─── ANSI utilities ─────────────────────────────────────────────────────── */
|
|
3986
|
+
|
|
3987
|
+
// Measure visible length of string (strip ANSI escape codes)
|
|
3988
|
+
function _visLen(s) {
|
|
3989
|
+
return _stripAnsi(s).length;
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
function _stripAnsi(s) {
|
|
3993
|
+
// eslint-disable-next-line no-control-regex
|
|
3994
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
2357
3995
|
}
|
|
2358
3996
|
|
|
2359
3997
|
/**
|
|
@@ -4463,6 +6101,260 @@ function _computeEvidenceWeight(payload) {
|
|
|
4463
6101
|
);
|
|
4464
6102
|
}
|
|
4465
6103
|
|
|
6104
|
+
/**
|
|
6105
|
+
* @svrnsec/pulse — Terminal Result Renderer
|
|
6106
|
+
*
|
|
6107
|
+
* Pretty-prints probe results to the terminal for Node.js server usage.
|
|
6108
|
+
* Used by middleware and the CLI so developers see clean, actionable output
|
|
6109
|
+
* during integration and debugging — not raw JSON.
|
|
6110
|
+
*
|
|
6111
|
+
* Zero dependencies. Pure ANSI escape codes.
|
|
6112
|
+
* Automatically disabled when stdout is not a TTY or NO_COLOR is set.
|
|
6113
|
+
*/
|
|
6114
|
+
|
|
6115
|
+
/* ─── TTY guard ──────────────────────────────────────────────────────────── */
|
|
6116
|
+
|
|
6117
|
+
const isTTY = () =>
|
|
6118
|
+
typeof process !== 'undefined' &&
|
|
6119
|
+
process.stderr?.isTTY === true &&
|
|
6120
|
+
process.env?.NO_COLOR == null;
|
|
6121
|
+
|
|
6122
|
+
const c = isTTY;
|
|
6123
|
+
|
|
6124
|
+
/* ─── ANSI color palette ─────────────────────────────────────────────────── */
|
|
6125
|
+
|
|
6126
|
+
const A = {
|
|
6127
|
+
reset: '\x1b[0m',
|
|
6128
|
+
bold: '\x1b[1m',
|
|
6129
|
+
cyan: '\x1b[36m',
|
|
6130
|
+
gray: '\x1b[90m',
|
|
6131
|
+
// foreground — bright
|
|
6132
|
+
bred: '\x1b[91m',
|
|
6133
|
+
bgreen: '\x1b[92m',
|
|
6134
|
+
byellow: '\x1b[93m',
|
|
6135
|
+
bmagenta:'\x1b[95m',
|
|
6136
|
+
bcyan: '\x1b[96m',
|
|
6137
|
+
bwhite: '\x1b[97m',
|
|
6138
|
+
};
|
|
6139
|
+
|
|
6140
|
+
const paint = (code, s) => c() ? `${code}${s}${A.reset}` : s;
|
|
6141
|
+
const bold = (s) => paint(A.bold, s);
|
|
6142
|
+
const gray = (s) => paint(A.gray, s);
|
|
6143
|
+
const cyan = (s) => paint(A.cyan, s);
|
|
6144
|
+
const green = (s) => paint(A.bgreen, s);
|
|
6145
|
+
const red = (s) => paint(A.bred, s);
|
|
6146
|
+
const yel = (s) => paint(A.byellow, s);
|
|
6147
|
+
const mag = (s) => paint(A.bmagenta,s);
|
|
6148
|
+
const wh = (s) => paint(A.bwhite, s);
|
|
6149
|
+
|
|
6150
|
+
function stripAnsi(s) {
|
|
6151
|
+
// eslint-disable-next-line no-control-regex
|
|
6152
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
6153
|
+
}
|
|
6154
|
+
const visLen = (s) => stripAnsi(s).length;
|
|
6155
|
+
|
|
6156
|
+
/* ─── bar renderer ───────────────────────────────────────────────────────── */
|
|
6157
|
+
|
|
6158
|
+
/**
|
|
6159
|
+
* Render a horizontal progress / confidence bar.
|
|
6160
|
+
* @param {number} pct 0–1
|
|
6161
|
+
* @param {number} width character width of the bar
|
|
6162
|
+
* @param {string} fillCode ANSI color code for filled blocks
|
|
6163
|
+
*/
|
|
6164
|
+
function bar(pct, width = 20, fillCode = A.bgreen) {
|
|
6165
|
+
const filled = Math.round(Math.min(1, Math.max(0, pct)) * width);
|
|
6166
|
+
const empty = width - filled;
|
|
6167
|
+
const fill = c() ? `${fillCode}${'█'.repeat(filled)}${A.reset}` : '█'.repeat(filled);
|
|
6168
|
+
const void_ = gray('░'.repeat(empty));
|
|
6169
|
+
return fill + void_;
|
|
6170
|
+
}
|
|
6171
|
+
|
|
6172
|
+
/* ─── verdict badge ──────────────────────────────────────────────────────── */
|
|
6173
|
+
|
|
6174
|
+
function verdictBadge(result) {
|
|
6175
|
+
if (!result) return gray(' PENDING ');
|
|
6176
|
+
const { valid, score, confidence } = result;
|
|
6177
|
+
|
|
6178
|
+
if (valid && confidence === 'high') return green(' ✓ PASS ');
|
|
6179
|
+
if (valid && confidence === 'medium') return yel(' ⚠ PASS ');
|
|
6180
|
+
if (!valid && score < 0.3) return red(' ✗ BLOCKED ');
|
|
6181
|
+
return yel(' ⚠ REVIEW ');
|
|
6182
|
+
}
|
|
6183
|
+
|
|
6184
|
+
/* ─── renderProbeResult ──────────────────────────────────────────────────── */
|
|
6185
|
+
|
|
6186
|
+
/**
|
|
6187
|
+
* Print a formatted probe result card to stderr.
|
|
6188
|
+
*
|
|
6189
|
+
* @param {object} opts
|
|
6190
|
+
* @param {object} opts.payload - ProofPayload from pulse()
|
|
6191
|
+
* @param {string} opts.hash - BLAKE3 hex commitment
|
|
6192
|
+
* @param {object} [opts.result] - ValidationResult (server-side verify)
|
|
6193
|
+
* @param {object} [opts.enf] - EnfResult if available
|
|
6194
|
+
* @param {object} [opts.gpu] - GpuEntropyResult if available
|
|
6195
|
+
* @param {object} [opts.dram] - DramResult if available
|
|
6196
|
+
* @param {object} [opts.llm] - LlmResult if available
|
|
6197
|
+
* @param {number} [opts.elapsedMs] - total probe time
|
|
6198
|
+
*/
|
|
6199
|
+
function renderProbeResult({ payload, hash, result, enf, gpu, dram, llm, elapsedMs }) {
|
|
6200
|
+
if (!c()) return;
|
|
6201
|
+
|
|
6202
|
+
const W = 54;
|
|
6203
|
+
gray('─'.repeat(W));
|
|
6204
|
+
const vbar = gray('│');
|
|
6205
|
+
|
|
6206
|
+
const row = (label, value, valueColor = A.bwhite) => {
|
|
6207
|
+
const lbl = gray(label.padEnd(24));
|
|
6208
|
+
const val = c() ? `${valueColor}${value}${A.reset}` : String(value);
|
|
6209
|
+
const line = ` ${lbl}${val}`;
|
|
6210
|
+
const pad = ' '.repeat(Math.max(0, W - visLen(line) - 2));
|
|
6211
|
+
process.stderr.write(`${vbar}${line}${pad} ${vbar}\n`);
|
|
6212
|
+
};
|
|
6213
|
+
|
|
6214
|
+
const blank = () => {
|
|
6215
|
+
process.stderr.write(`${vbar}${' '.repeat(W + 2)}${vbar}\n`);
|
|
6216
|
+
};
|
|
6217
|
+
|
|
6218
|
+
const section = (title) => {
|
|
6219
|
+
const t = ` ${bold(title)}`;
|
|
6220
|
+
const pad = ' '.repeat(Math.max(0, W - visLen(t) - 2));
|
|
6221
|
+
process.stderr.write(`${vbar}${t}${pad} ${vbar}\n`);
|
|
6222
|
+
};
|
|
6223
|
+
|
|
6224
|
+
const badge = verdictBadge(result);
|
|
6225
|
+
const hashShort = hash ? hash.slice(0, 16) + '…' : 'pending';
|
|
6226
|
+
const elapsed = elapsedMs ? `${(elapsedMs / 1000).toFixed(2)}s` : '—';
|
|
6227
|
+
|
|
6228
|
+
const sigs = payload?.signals ?? {};
|
|
6229
|
+
const cls = payload?.classification ?? {};
|
|
6230
|
+
const jScore = cls.jitterScore ?? 0;
|
|
6231
|
+
|
|
6232
|
+
// ── Physics signals ──────────────────────────────────────────────────────
|
|
6233
|
+
const qe = sigs.entropy?.quantizationEntropy ?? 0;
|
|
6234
|
+
const hurst = sigs.entropy?.hurstExponent ?? 0;
|
|
6235
|
+
const cv = sigs.entropy?.timingsCV ?? 0;
|
|
6236
|
+
const ejrClass = qe >= 1.08 ? A.bgreen : qe >= 0.95 ? A.byellow : A.bred;
|
|
6237
|
+
const hwConf = result?.confidence === 'high' ? 1.0 : result?.confidence === 'medium' ? 0.65 : 0.3;
|
|
6238
|
+
const vmConf = 1 - hwConf;
|
|
6239
|
+
|
|
6240
|
+
// ── ENF signals ──────────────────────────────────────────────────────────
|
|
6241
|
+
const enfRegion = enf?.gridRegion === 'americas' ? '60 Hz Americas'
|
|
6242
|
+
: enf?.gridRegion === 'emea_apac' ? '50 Hz EMEA/APAC'
|
|
6243
|
+
: enf?.enfAvailable === false ? 'unavailable'
|
|
6244
|
+
: '—';
|
|
6245
|
+
const enfColor = enf?.ripplePresent ? A.bgreen : enf?.enfAvailable === false ? A.gray : A.byellow;
|
|
6246
|
+
|
|
6247
|
+
// ── GPU signals ──────────────────────────────────────────────────────────
|
|
6248
|
+
const gpuStr = gpu?.gpuPresent
|
|
6249
|
+
? (gpu.isSoftware ? red('Software renderer') : green(gpu.vendorString ?? 'GPU detected'))
|
|
6250
|
+
: gray('unavailable');
|
|
6251
|
+
|
|
6252
|
+
// ── DRAM signals ─────────────────────────────────────────────────────────
|
|
6253
|
+
const dramStr = dram?.refreshPresent
|
|
6254
|
+
? green(`${(dram.refreshPeriodMs ?? 0).toFixed(1)} ms (DDR4 JEDEC ✓)`)
|
|
6255
|
+
: dram ? red('No refresh cycle (VM)') : gray('unavailable');
|
|
6256
|
+
|
|
6257
|
+
// ── LLM signals ──────────────────────────────────────────────────────────
|
|
6258
|
+
const llmStr = llm
|
|
6259
|
+
? (llm.aiConf > 0.7 ? red(`AI agent ${(llm.aiConf * 100).toFixed(0)}%`) : green(`Human ${((1 - llm.aiConf) * 100).toFixed(0)}%`))
|
|
6260
|
+
: gray('no bio data');
|
|
6261
|
+
|
|
6262
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
6263
|
+
const topTitle = ` ${mag('SVRN')}${wh(':PULSE')} ${badge}`;
|
|
6264
|
+
const topPad = ' '.repeat(Math.max(0, W - visLen(topTitle) - 2));
|
|
6265
|
+
const topBorder = gray('╭' + '─'.repeat(W + 2) + '╮');
|
|
6266
|
+
const botBorder = gray('╰' + '─'.repeat(W + 2) + '╯');
|
|
6267
|
+
|
|
6268
|
+
process.stderr.write('\n');
|
|
6269
|
+
process.stderr.write(topBorder + '\n');
|
|
6270
|
+
process.stderr.write(`${vbar}${topTitle}${topPad} ${vbar}\n`);
|
|
6271
|
+
process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
6272
|
+
blank();
|
|
6273
|
+
|
|
6274
|
+
section('PHYSICS LAYER');
|
|
6275
|
+
blank();
|
|
6276
|
+
row('Jitter score', (jScore * 100).toFixed(1) + '%', jScore > 0.7 ? A.bgreen : jScore > 0.45 ? A.byellow : A.bred);
|
|
6277
|
+
row('QE (entropy)', qe.toFixed(3), ejrClass);
|
|
6278
|
+
row('Hurst exponent', hurst.toFixed(4), Math.abs(hurst - 0.5) < 0.1 ? A.bgreen : A.byellow);
|
|
6279
|
+
row('Timing CV', cv.toFixed(4), cv > 0.08 ? A.bgreen : A.byellow);
|
|
6280
|
+
row('Timer granularity',`${((sigs.entropy?.timerGranularityMs ?? 0) * 1000).toFixed(1)} µs`, A.bcyan);
|
|
6281
|
+
blank();
|
|
6282
|
+
|
|
6283
|
+
const hwBar = bar(hwConf, 18, A.bgreen);
|
|
6284
|
+
const vmBar = bar(vmConf, 18, A.bred);
|
|
6285
|
+
row('HW confidence', hwBar + ' ' + (hwConf * 100).toFixed(0) + '%');
|
|
6286
|
+
row('VM confidence', vmBar + ' ' + (vmConf * 100).toFixed(0) + '%');
|
|
6287
|
+
|
|
6288
|
+
blank();
|
|
6289
|
+
process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
6290
|
+
blank();
|
|
6291
|
+
|
|
6292
|
+
section('SIGNAL LAYERS');
|
|
6293
|
+
blank();
|
|
6294
|
+
row('Grid (ENF)', enfRegion, enfColor);
|
|
6295
|
+
process.stderr.write(`${vbar} ${gray('GPU (thermal)')}${' '.repeat(10)}${gpuStr} ${vbar}\n`);
|
|
6296
|
+
process.stderr.write(`${vbar} ${gray('DRAM refresh')} ${' '.repeat(11)}${dramStr} ${vbar}\n`);
|
|
6297
|
+
process.stderr.write(`${vbar} ${gray('Behavioral (LLM)')}${' '.repeat(7)}${llmStr} ${vbar}\n`);
|
|
6298
|
+
|
|
6299
|
+
blank();
|
|
6300
|
+
process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
6301
|
+
blank();
|
|
6302
|
+
|
|
6303
|
+
section('PROOF');
|
|
6304
|
+
blank();
|
|
6305
|
+
row('BLAKE3', hashShort, A.bcyan);
|
|
6306
|
+
row('Nonce', (payload?.nonce ?? '').slice(0, 16) + '…', A.gray);
|
|
6307
|
+
row('Elapsed', elapsed, A.gray);
|
|
6308
|
+
if (result) {
|
|
6309
|
+
row('Server verdict', result.valid ? 'valid' : 'rejected', result.valid ? A.bgreen : A.bred);
|
|
6310
|
+
row('Score', ((result.score ?? 0) * 100).toFixed(1) + '%', A.bwhite);
|
|
6311
|
+
if ((result.riskFlags ?? []).length > 0) {
|
|
6312
|
+
blank();
|
|
6313
|
+
row('Risk flags', result.riskFlags.join(', '), A.byellow);
|
|
6314
|
+
}
|
|
6315
|
+
}
|
|
6316
|
+
blank();
|
|
6317
|
+
process.stderr.write(botBorder + '\n\n');
|
|
6318
|
+
}
|
|
6319
|
+
|
|
6320
|
+
/* ─── renderError ────────────────────────────────────────────────────────── */
|
|
6321
|
+
|
|
6322
|
+
/**
|
|
6323
|
+
* Print a formatted error card for pulse() failures.
|
|
6324
|
+
* @param {Error|string} err
|
|
6325
|
+
*/
|
|
6326
|
+
function renderError(err) {
|
|
6327
|
+
if (!c()) return;
|
|
6328
|
+
const msg = err?.message ?? String(err);
|
|
6329
|
+
const W = 54;
|
|
6330
|
+
const vbar = gray('│');
|
|
6331
|
+
|
|
6332
|
+
process.stderr.write('\n');
|
|
6333
|
+
process.stderr.write(red('╭' + '─'.repeat(W + 2) + '╮') + '\n');
|
|
6334
|
+
process.stderr.write(`${red('│')} ${red('✗')} ${bold('SVRN:PULSE — probe failed')}${' '.repeat(Math.max(0, W - 28))} ${red('│')}\n`);
|
|
6335
|
+
process.stderr.write(red('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
6336
|
+
process.stderr.write(`${vbar} ${gray(msg.slice(0, W - 2).padEnd(W))} ${vbar}\n`);
|
|
6337
|
+
process.stderr.write(red('╰' + '─'.repeat(W + 2) + '╯') + '\n\n');
|
|
6338
|
+
}
|
|
6339
|
+
|
|
6340
|
+
/* ─── renderUpdateBanner ─────────────────────────────────────────────────── */
|
|
6341
|
+
|
|
6342
|
+
/**
|
|
6343
|
+
* Render a simple one-line update available hint inline (used by middleware).
|
|
6344
|
+
* @param {string} latest
|
|
6345
|
+
*/
|
|
6346
|
+
function renderInlineUpdateHint(latest) {
|
|
6347
|
+
if (!c()) return;
|
|
6348
|
+
process.stderr.write(
|
|
6349
|
+
gray(' ╴╴╴ ') +
|
|
6350
|
+
yel('update available ') +
|
|
6351
|
+
gray(latest) +
|
|
6352
|
+
' ' + cyan('npm i @svrnsec/pulse@latest') +
|
|
6353
|
+
gray(' ╴╴╴') +
|
|
6354
|
+
'\n'
|
|
6355
|
+
);
|
|
6356
|
+
}
|
|
6357
|
+
|
|
4466
6358
|
/**
|
|
4467
6359
|
* @sovereign/pulse
|
|
4468
6360
|
*
|
|
@@ -4510,6 +6402,11 @@ function _computeEvidenceWeight(payload) {
|
|
|
4510
6402
|
*/
|
|
4511
6403
|
|
|
4512
6404
|
|
|
6405
|
+
// Register background update check — fires once at process startup.
|
|
6406
|
+
// Shows a styled notification box after the process exits if a newer version
|
|
6407
|
+
// is available. No-op in browser environments and non-TTY outputs.
|
|
6408
|
+
notifyOnExit();
|
|
6409
|
+
|
|
4513
6410
|
// ---------------------------------------------------------------------------
|
|
4514
6411
|
// Hosted API mode — pulse({ apiKey }) with zero server setup
|
|
4515
6412
|
// ---------------------------------------------------------------------------
|
|
@@ -4665,25 +6562,47 @@ async function _runProbe(opts) {
|
|
|
4665
6562
|
const bioSnapshot = bio.snapshot(entropyResult.timings);
|
|
4666
6563
|
|
|
4667
6564
|
if (requireBio && !bioSnapshot.hasActivity) {
|
|
4668
|
-
throw new Error('@
|
|
6565
|
+
throw new Error('@svrnsec/pulse: no bio activity detected (requireBio=true)');
|
|
4669
6566
|
}
|
|
4670
6567
|
|
|
4671
6568
|
_emit(onProgress, 'bio_done');
|
|
4672
6569
|
|
|
4673
|
-
// ── Phase 4:
|
|
6570
|
+
// ── Phase 4: Extended signal collection (non-blocking, best-effort) ───────
|
|
6571
|
+
// ENF, GPU, DRAM, and LLM detectors run in parallel after the core probe.
|
|
6572
|
+
// Each gracefully returns a null/unavailable result if the environment does
|
|
6573
|
+
// not support it (e.g. no WebGPU, no SharedArrayBuffer, no bio events).
|
|
6574
|
+
const [enfResult, gpuResult, dramResult, llmResult] = await Promise.all([
|
|
6575
|
+
collectEnfTimings().catch(() => null),
|
|
6576
|
+
collectGpuEntropy().catch(() => null),
|
|
6577
|
+
collectDramTimings().catch(() => null),
|
|
6578
|
+
Promise.resolve(detectLlmAgent(bioSnapshot)).catch(() => null),
|
|
6579
|
+
]);
|
|
6580
|
+
|
|
6581
|
+
_emit(onProgress, 'extended_done', {
|
|
6582
|
+
enf: enfResult?.verdict,
|
|
6583
|
+
gpu: gpuResult?.verdict,
|
|
6584
|
+
dram: dramResult?.verdict,
|
|
6585
|
+
llm: llmResult?.aiConf,
|
|
6586
|
+
});
|
|
6587
|
+
|
|
6588
|
+
// ── Phase 5: Jitter analysis ───────────────────────────────────────────────
|
|
4674
6589
|
const jitterAnalysis = classifyJitter(entropyResult.timings, {
|
|
4675
6590
|
autocorrelations: entropyResult.autocorrelations,
|
|
4676
6591
|
});
|
|
4677
6592
|
|
|
4678
6593
|
_emit(onProgress, 'analysis_done');
|
|
4679
6594
|
|
|
4680
|
-
// ── Phase
|
|
6595
|
+
// ── Phase 6: Build proof & commitment ─────────────────────────────────────
|
|
4681
6596
|
const payload = buildProof({
|
|
4682
6597
|
entropy: entropyResult,
|
|
4683
6598
|
jitter: jitterAnalysis,
|
|
4684
6599
|
bio: bioSnapshot,
|
|
4685
6600
|
canvas: canvasResult,
|
|
4686
6601
|
audio: audioResult,
|
|
6602
|
+
enf: enfResult,
|
|
6603
|
+
gpu: gpuResult,
|
|
6604
|
+
dram: dramResult,
|
|
6605
|
+
llm: llmResult,
|
|
4687
6606
|
nonce,
|
|
4688
6607
|
});
|
|
4689
6608
|
|
|
@@ -4693,9 +6612,13 @@ async function _runProbe(opts) {
|
|
|
4693
6612
|
score: jitterAnalysis.score,
|
|
4694
6613
|
confidence: _scoreToLabel(jitterAnalysis.score),
|
|
4695
6614
|
flags: jitterAnalysis.flags,
|
|
6615
|
+
enf: enfResult?.verdict,
|
|
6616
|
+
gpu: gpuResult?.verdict,
|
|
6617
|
+
dram: dramResult?.verdict,
|
|
6618
|
+
llmConf: llmResult?.aiConf ?? null,
|
|
4696
6619
|
});
|
|
4697
6620
|
|
|
4698
|
-
return commitment;
|
|
6621
|
+
return { ...commitment, extended: { enf: enfResult, gpu: gpuResult, dram: dramResult, llm: llmResult } };
|
|
4699
6622
|
}
|
|
4700
6623
|
|
|
4701
6624
|
// ---------------------------------------------------------------------------
|
|
@@ -4901,10 +6824,19 @@ var pulse_core = /*#__PURE__*/Object.freeze({
|
|
|
4901
6824
|
run_memory_probe: run_memory_probe
|
|
4902
6825
|
});
|
|
4903
6826
|
|
|
6827
|
+
exports.CURRENT_VERSION = CURRENT_VERSION;
|
|
4904
6828
|
exports.Fingerprint = Fingerprint;
|
|
6829
|
+
exports.checkForUpdate = checkForUpdate;
|
|
6830
|
+
exports.collectDramTimings = collectDramTimings;
|
|
6831
|
+
exports.collectEnfTimings = collectEnfTimings;
|
|
6832
|
+
exports.collectGpuEntropy = collectGpuEntropy;
|
|
6833
|
+
exports.detectLlmAgent = detectLlmAgent;
|
|
4905
6834
|
exports.detectProvider = detectProvider;
|
|
4906
6835
|
exports.generateNonce = generateNonce;
|
|
4907
6836
|
exports.pulse = pulse;
|
|
6837
|
+
exports.renderError = renderError;
|
|
6838
|
+
exports.renderInlineUpdateHint = renderInlineUpdateHint;
|
|
6839
|
+
exports.renderProbeResult = renderProbeResult;
|
|
4908
6840
|
exports.runHeuristicEngine = runHeuristicEngine;
|
|
4909
6841
|
exports.validateProof = validateProof;
|
|
4910
6842
|
//# sourceMappingURL=pulse.cjs.js.map
|