@svrnsec/pulse 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @svrnsec/pulse CLI — main entry point
3
+ *
4
+ * Commands:
5
+ * scan run the full probe locally
6
+ * challenge generate a signed challenge nonce
7
+ * version show version and check for updates
8
+ * help show this help text
9
+ */
10
+
11
+ import { parseArgs } from './args.js';
12
+ import { printBanner, checkForUpdate, CURRENT_VERSION } from '../update-notifier.js';
13
+
14
+ const isTTY = () => process.stderr.isTTY && !process.env.NO_COLOR;
15
+ const A = { reset:'\x1b[0m', gray:'\x1b[90m', bcyan:'\x1b[96m',
16
+ bwhite:'\x1b[97m', bmagenta:'\x1b[95m', bgreen:'\x1b[92m', byellow:'\x1b[93m' };
17
+ const c = (code, s) => isTTY() ? `${code}${s}${A.reset}` : s;
18
+ const gy = (s) => c(A.gray, s);
19
+ const cy = (s) => c(A.bcyan, s);
20
+ const wh = (s) => c(A.bwhite, s);
21
+ const gr = (s) => c(A.bgreen, s);
22
+ const ye = (s) => c(A.byellow, s);
23
+ const mg = (s) => c(A.bmagenta, s);
24
+
25
+ function help() {
26
+ process.stderr.write(`
27
+ ${mg('SVRN')}${wh(':PULSE')} ${gy(`v${CURRENT_VERSION}`)} ${gy('Physical Turing Test')}
28
+
29
+ ${wh('Usage')}
30
+ ${cy('npx svrnsec-pulse')} ${gy('<command>')} ${gy('[options]')}
31
+
32
+ ${wh('Commands')}
33
+ ${cy('scan')} Run the full probe locally and show a TrustScore
34
+ ${cy('challenge')} Generate a signed HMAC challenge nonce
35
+ ${cy('version')} Show version and check for updates
36
+ ${cy('help')} Show this help text
37
+
38
+ ${wh('Scan options')}
39
+ ${gy('--json')} Output raw JSON (pipe-friendly)
40
+ ${gy('--iterations')} Override probe iteration count ${gy('(default: 200)')}
41
+ ${gy('--no-banner')} Suppress the banner
42
+
43
+ ${wh('Challenge options')}
44
+ ${gy('--secret')} Server secret for HMAC signing ${gy('(or set PULSE_SECRET env)')}
45
+ ${gy('--ttl')} Challenge TTL in seconds ${gy('(default: 300)')}
46
+ ${gy('--json')} Output raw JSON
47
+
48
+ ${wh('Examples')}
49
+ ${gy('$')} ${cy('npx svrnsec-pulse scan')}
50
+ ${gy('$')} ${cy('npx svrnsec-pulse scan --json | jq .trustScore.score')}
51
+ ${gy('$')} ${cy('npx svrnsec-pulse challenge --secret $PULSE_SECRET')}
52
+ ${gy('$')} ${cy('PULSE_SECRET=mysecret npx svrnsec-pulse challenge --json')}
53
+
54
+ ${wh('Environment')}
55
+ ${gy('PULSE_SECRET')} Default server secret for challenge signing
56
+ ${gy('NO_COLOR')} Disable ANSI colors
57
+ ${gy('PULSE_NO_UPDATE')} Disable update notifications
58
+
59
+ ${gy(' Docs: https://github.com/ayronny14-alt/Svrn-Pulse-Security#readme')}
60
+ `);
61
+ }
62
+
63
+ async function cmdChallenge(args) {
64
+ const secret = args.get('secret') ?? process.env.PULSE_SECRET;
65
+ if (!secret) {
66
+ process.stderr.write(
67
+ ye('⚠ ') + 'No secret provided.\n' +
68
+ gy(' Pass --secret <value> or set PULSE_SECRET env var.\n') +
69
+ gy(' Generate one: ') + cy('npx svrnsec-pulse challenge --generate-secret\n')
70
+ );
71
+ process.exit(1);
72
+ }
73
+
74
+ if (args.has('generate-secret')) {
75
+ const { generateSecret } = await import('../proof/challenge.js');
76
+ const s = generateSecret();
77
+ if (args.has('json')) {
78
+ process.stdout.write(JSON.stringify({ secret: s }) + '\n');
79
+ } else {
80
+ process.stderr.write(gr('Generated secret (store in env):\n'));
81
+ process.stdout.write(s + '\n');
82
+ }
83
+ return;
84
+ }
85
+
86
+ const { createChallenge } = await import('../proof/challenge.js');
87
+ const ttlMs = (parseInt(args.get('ttl', '300'), 10) || 300) * 1000;
88
+ const challenge = createChallenge(secret, { ttlMs });
89
+
90
+ if (args.has('json')) {
91
+ process.stdout.write(JSON.stringify(challenge, null, 2) + '\n');
92
+ } else {
93
+ process.stderr.write('\n');
94
+ process.stderr.write(gy(' nonce ') + wh(challenge.nonce) + '\n');
95
+ process.stderr.write(gy(' issuedAt ') + gy(new Date(challenge.issuedAt).toISOString()) + '\n');
96
+ process.stderr.write(gy(' expiresAt ') + gy(new Date(challenge.expiresAt).toISOString()) + '\n');
97
+ process.stderr.write(gy(' sig ') + cy(challenge.sig) + '\n\n');
98
+ }
99
+ }
100
+
101
+ async function cmdVersion(args) {
102
+ if (args.has('json')) {
103
+ const { latest, updateAvailable } = await checkForUpdate({ silent: true });
104
+ process.stdout.write(JSON.stringify({ version: CURRENT_VERSION, latest, updateAvailable }) + '\n');
105
+ return;
106
+ }
107
+
108
+ process.stderr.write(`\n${mg('SVRN')}${wh(':PULSE')} ${gy('v' + CURRENT_VERSION)}\n\n`);
109
+ const { latest, updateAvailable } = await checkForUpdate({ silent: true });
110
+ if (updateAvailable) {
111
+ process.stderr.write(ye(` Update available: ${latest}\n`));
112
+ process.stderr.write(gy(` Run: `) + cy('npm i @svrnsec/pulse@latest') + '\n');
113
+ } else if (latest) {
114
+ process.stderr.write(gr(' Up to date.\n'));
115
+ }
116
+ process.stderr.write('\n');
117
+ }
118
+
119
+ export async function run(argv = process.argv.slice(2)) {
120
+ const args = parseArgs(argv);
121
+ const cmd = args.command ?? 'help';
122
+
123
+ try {
124
+ switch (cmd) {
125
+ case 'scan': {
126
+ const { runScan } = await import('./commands/scan.js');
127
+ await runScan(args);
128
+ break;
129
+ }
130
+ case 'challenge':
131
+ case 'ch':
132
+ await cmdChallenge(args);
133
+ break;
134
+ case 'version':
135
+ case '-v':
136
+ case '--version':
137
+ await cmdVersion(args);
138
+ break;
139
+ case 'help':
140
+ case '-h':
141
+ case '--help':
142
+ help();
143
+ break;
144
+ default:
145
+ process.stderr.write(ye(`Unknown command: ${cmd}\n`));
146
+ help();
147
+ process.exit(1);
148
+ }
149
+ } catch (err) {
150
+ process.stderr.write(
151
+ c(A.bmagenta + '\x1b[1m', 'SVRN:PULSE error') + '\n' +
152
+ gy(err.message) + '\n'
153
+ );
154
+ if (process.env.DEBUG) process.stderr.write(err.stack + '\n');
155
+ process.exit(1);
156
+ }
157
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @sovereign/pulse — Adaptive Entropy Probe
3
+ *
4
+ * Runs the WASM probe in batches and stops early once the signal is decisive.
5
+ *
6
+ * Why this works:
7
+ * A KVM VM with QE=1.27 and lag-1 autocorr=0.67 is unambiguously a VM after
8
+ * just 50 iterations. Running 200 iterations confirms what was already obvious
9
+ * at 50 — it adds no new information but wastes 3 seconds of user time.
10
+ *
11
+ * Conversely, a physical device with healthy entropy needs more data to
12
+ * rule out edge cases, so it runs longer.
13
+ *
14
+ * Speed profile:
15
+ * Obvious VM (QE < 1.5, lag1 > 0.60) → stops at 50 iters → ~0.9s (75% faster)
16
+ * Clear HW (QE > 3.5, lag1 < 0.10) → stops at ~100 iters → ~1.8s (50% faster)
17
+ * Ambiguous (borderline metrics) → runs full 200 iters → ~3.5s (same)
18
+ */
19
+
20
+ import { detectQuantizationEntropy } from '../analysis/jitter.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Quick classifier (cheap, runs after every batch)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Fast signal-quality check. No Hurst, no thermal analysis — just the three
28
+ * metrics that converge quickest: QE, CV, and lag-1 autocorrelation.
29
+ *
30
+ * @param {number[]} timings
31
+ * @returns {{ vmConf: number, hwConf: number, qe: number, cv: number, lag1: number }}
32
+ */
33
+ export function quickSignal(timings) {
34
+ const n = timings.length;
35
+ const mean = timings.reduce((s, v) => s + v, 0) / n;
36
+ const variance = timings.reduce((s, v) => s + (v - mean) ** 2, 0) / n;
37
+ const cv = mean > 0 ? Math.sqrt(variance) / mean : 0;
38
+ const qe = detectQuantizationEntropy(timings);
39
+
40
+ // Pearson autocorrelation at lag-1 (O(n), fits in a single pass)
41
+ let num = 0, da = 0, db = 0;
42
+ for (let i = 0; i < n - 1; i++) {
43
+ const a = timings[i] - mean;
44
+ const b = timings[i + 1] - mean;
45
+ num += a * b;
46
+ da += a * a;
47
+ db += b * b;
48
+ }
49
+ const lag1 = Math.sqrt(da * db) < 1e-14 ? 0 : num / Math.sqrt(da * db);
50
+
51
+ // VM confidence: each factor independently identifies the hypervisor footprint
52
+ const vmConf = Math.min(1,
53
+ (qe < 1.50 ? 0.40 : qe < 2.00 ? 0.20 : 0.0) +
54
+ (lag1 > 0.60 ? 0.35 : lag1 > 0.40 ? 0.18 : 0.0) +
55
+ (cv < 0.04 ? 0.25 : cv < 0.07 ? 0.10 : 0.0)
56
+ );
57
+
58
+ // HW confidence: must see all three positive signals together
59
+ const hwConf = Math.min(1,
60
+ (qe > 3.50 ? 0.38 : qe > 3.00 ? 0.22 : 0.0) +
61
+ (Math.abs(lag1) < 0.10 ? 0.32 : Math.abs(lag1) < 0.20 ? 0.15 : 0.0) +
62
+ (cv > 0.10 ? 0.30 : cv > 0.07 ? 0.14 : 0.0)
63
+ );
64
+
65
+ return { vmConf, hwConf, qe, cv, lag1 };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // collectEntropyAdaptive
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * @param {object} opts
74
+ * @param {number} [opts.minIterations=50] - never stop before this
75
+ * @param {number} [opts.maxIterations=200] - hard cap
76
+ * @param {number} [opts.batchSize=25] - WASM call granularity
77
+ * @param {number} [opts.vmThreshold=0.85] - stop early if VM confidence ≥ this
78
+ * @param {number} [opts.hwThreshold=0.80] - stop early if HW confidence ≥ this
79
+ * @param {number} [opts.hwMinIterations=75] - physical needs more data to confirm
80
+ * @param {number} [opts.matrixSize=64]
81
+ * @param {Function} [opts.onBatch] - called after each batch with interim signal
82
+ * @param {string} [opts.wasmPath]
83
+ * @param {Function} wasmModule - pre-initialised WASM module
84
+ * @returns {Promise<AdaptiveEntropyResult>}
85
+ */
86
+ export async function collectEntropyAdaptive(wasmModule, opts = {}) {
87
+ const {
88
+ minIterations = 50,
89
+ maxIterations = 200,
90
+ batchSize = 25,
91
+ vmThreshold = 0.85,
92
+ hwThreshold = 0.80,
93
+ hwMinIterations = 75,
94
+ matrixSize = 64,
95
+ onBatch,
96
+ } = opts;
97
+
98
+ const wasm = wasmModule;
99
+ const allTimings = [];
100
+ const batches = []; // per-batch timing snapshots
101
+ let stoppedAt = null; // { reason, iterations, vmConf, hwConf }
102
+ let checksum = 0;
103
+
104
+ const t_start = Date.now();
105
+
106
+ while (allTimings.length < maxIterations) {
107
+ const n = Math.min(batchSize, maxIterations - allTimings.length);
108
+ const result = wasm.run_entropy_probe(n, matrixSize);
109
+ const chunk = Array.from(result.timings);
110
+
111
+ allTimings.push(...chunk);
112
+ checksum += result.checksum;
113
+
114
+ const sig = quickSignal(allTimings);
115
+ batches.push({ iterations: allTimings.length, ...sig });
116
+
117
+ // Fire progress callback with live signal so callers can stream to UI
118
+ if (typeof onBatch === 'function') {
119
+ try {
120
+ onBatch({
121
+ iterations: allTimings.length,
122
+ maxIterations,
123
+ pct: Math.round(allTimings.length / maxIterations * 100),
124
+ vmConf: sig.vmConf,
125
+ hwConf: sig.hwConf,
126
+ qe: sig.qe,
127
+ cv: sig.cv,
128
+ lag1: sig.lag1,
129
+ // Thresholds: 0.70 — high enough that a legitimate device won't be
130
+ // shown a false early verdict from a noisy first batch.
131
+ // 'borderline' surfaces when one axis is moderate but not decisive.
132
+ earlyVerdict: sig.vmConf > 0.70 ? 'vm'
133
+ : sig.hwConf > 0.70 ? 'physical'
134
+ : (sig.vmConf > 0.45 || sig.hwConf > 0.45) ? 'borderline'
135
+ : 'uncertain',
136
+ });
137
+ } catch {}
138
+ }
139
+
140
+ // ── Early-exit checks ──────────────────────────────────────────────────
141
+ if (allTimings.length < minIterations) continue;
142
+
143
+ if (sig.vmConf >= vmThreshold) {
144
+ stoppedAt = { reason: 'VM_SIGNAL_DECISIVE', vmConf: sig.vmConf, hwConf: sig.hwConf };
145
+ break;
146
+ }
147
+
148
+ if (allTimings.length >= hwMinIterations && sig.hwConf >= hwThreshold) {
149
+ stoppedAt = { reason: 'PHYSICAL_SIGNAL_DECISIVE', vmConf: sig.vmConf, hwConf: sig.hwConf };
150
+ break;
151
+ }
152
+ }
153
+
154
+ const elapsed = Date.now() - t_start;
155
+ const iterationsRan = allTimings.length;
156
+ const iterationsSaved = maxIterations - iterationsRan;
157
+ const speedupFactor = maxIterations / iterationsRan;
158
+
159
+ // ── Resolution probe using cached WASM call ────────────────────────────
160
+ const resResult = wasm.run_entropy_probe(1, 4); // tiny probe for resolution
161
+ const resProbe = Array.from(resResult.resolution_probe ?? []);
162
+
163
+ const resDeltas = [];
164
+ for (let i = 1; i < resProbe.length; i++) {
165
+ const d = resProbe[i] - resProbe[i - 1];
166
+ if (d > 0) resDeltas.push(d);
167
+ }
168
+
169
+ return {
170
+ timings: allTimings,
171
+ iterations: iterationsRan,
172
+ maxIterations,
173
+ checksum: checksum.toString(),
174
+ resolutionProbe: resProbe,
175
+ timerGranularityMs: resDeltas.length
176
+ ? resDeltas.reduce((a, b) => Math.min(a, b), Infinity)
177
+ : null,
178
+ earlyExit: stoppedAt ? {
179
+ ...stoppedAt,
180
+ iterationsSaved,
181
+ timeSavedMs: Math.round(iterationsSaved * (elapsed / iterationsRan)),
182
+ speedupFactor: +speedupFactor.toFixed(2),
183
+ } : null,
184
+ batches,
185
+ elapsedMs: elapsed,
186
+ collectedAt: t_start,
187
+ matrixSize,
188
+ phased: false, // adaptive replaces phased for speed
189
+ };
190
+ }
191
+
192
+ /**
193
+ * @typedef {object} AdaptiveEntropyResult
194
+ * @property {number[]} timings
195
+ * @property {number} iterations - how many actually ran
196
+ * @property {number} maxIterations - cap that was set
197
+ * @property {object|null} earlyExit - null if ran to completion
198
+ * @property {object[]} batches - per-batch signal snapshots
199
+ * @property {number} elapsedMs
200
+ */
@@ -0,0 +1,287 @@
1
+ /**
2
+ * @sovereign/pulse — Bio-Binding Layer
3
+ *
4
+ * Captures mouse-movement micro-stutters and keystroke-cadence dynamics
5
+ * WHILE the hardware entropy probe is running. Computes the
6
+ * "Interference Coefficient": how much human input jitters hardware timing.
7
+ *
8
+ * PRIVACY NOTE: Only timing deltas are retained. No key labels, no raw
9
+ * (x, y) coordinates, no content of any kind is stored or transmitted.
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Internal state
14
+ // ---------------------------------------------------------------------------
15
+ const MAX_EVENTS = 500; // rolling buffer cap
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // BioCollector class
19
+ // ---------------------------------------------------------------------------
20
+ export class BioCollector {
21
+ constructor() {
22
+ this._mouseEvents = []; // { t: DOMHighResTimeStamp, dx, dy }
23
+ this._keyEvents = []; // { t, type: 'down'|'up', dwell: ms|null }
24
+ this._lastKey = {}; // keyCode → { downAt: t }
25
+ this._lastMouse = null; // { t, x, y }
26
+ this._startTime = null;
27
+ this._active = false;
28
+
29
+ // Bound handlers (needed for removeEventListener)
30
+ this._onMouseMove = this._onMouseMove.bind(this);
31
+ this._onKeyDown = this._onKeyDown.bind(this);
32
+ this._onKeyUp = this._onKeyUp.bind(this);
33
+ }
34
+
35
+ // ── Lifecycle ────────────────────────────────────────────────────────────
36
+
37
+ start() {
38
+ if (this._active) return;
39
+ this._active = true;
40
+ this._startTime = performance.now();
41
+
42
+ if (typeof window !== 'undefined') {
43
+ window.addEventListener('pointermove', this._onMouseMove, { passive: true });
44
+ window.addEventListener('keydown', this._onKeyDown, { passive: true });
45
+ window.addEventListener('keyup', this._onKeyUp, { passive: true });
46
+ }
47
+ }
48
+
49
+ stop() {
50
+ if (!this._active) return;
51
+ this._active = false;
52
+
53
+ if (typeof window !== 'undefined') {
54
+ window.removeEventListener('pointermove', this._onMouseMove);
55
+ window.removeEventListener('keydown', this._onKeyDown);
56
+ window.removeEventListener('keyup', this._onKeyUp);
57
+ }
58
+ }
59
+
60
+ // ── Event handlers ────────────────────────────────────────────────────────
61
+
62
+ _onMouseMove(e) {
63
+ if (!this._active) return;
64
+ const t = e.timeStamp ?? performance.now();
65
+ const cur = { t, x: e.clientX, y: e.clientY };
66
+
67
+ if (this._lastMouse) {
68
+ const dt = t - this._lastMouse.t;
69
+ const dx = cur.x - this._lastMouse.x;
70
+ const dy = cur.y - this._lastMouse.y;
71
+ // Only store the delta, not absolute position (privacy)
72
+ if (this._mouseEvents.length < MAX_EVENTS) {
73
+ this._mouseEvents.push({ t, dt, dx, dy,
74
+ pressure: e.pressure ?? 0,
75
+ pointerType: e.pointerType ?? 'mouse' });
76
+ }
77
+ }
78
+ this._lastMouse = cur;
79
+ }
80
+
81
+ _onKeyDown(e) {
82
+ if (!this._active) return;
83
+ const t = e.timeStamp ?? performance.now();
84
+ // Store timestamp keyed by code (NOT key label)
85
+ this._lastKey[e.code] = { downAt: t };
86
+ }
87
+
88
+ _onKeyUp(e) {
89
+ if (!this._active) return;
90
+ const t = e.timeStamp ?? performance.now();
91
+ const rec = this._lastKey[e.code];
92
+ const dwell = rec ? (t - rec.downAt) : null;
93
+ delete this._lastKey[e.code];
94
+
95
+ if (this._keyEvents.length < MAX_EVENTS) {
96
+ // Only dwell time; key identity NOT stored.
97
+ this._keyEvents.push({ t, dwell });
98
+ }
99
+ }
100
+
101
+ // ── snapshot ─────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Returns a privacy-preserving statistical snapshot of collected bio signals.
105
+ * Raw events are summarised; nothing identifiable is included in the output.
106
+ *
107
+ * @param {number[]} computationTimings - entropy probe timing array
108
+ * @returns {BioSnapshot}
109
+ */
110
+ snapshot(computationTimings = []) {
111
+ const now = performance.now();
112
+ const durationMs = this._startTime != null ? (now - this._startTime) : 0;
113
+
114
+ // ── Mouse statistics ────────────────────────────────────────────────
115
+ const iei = this._mouseEvents.map(e => e.dt);
116
+ const velocities = this._mouseEvents.map(e =>
117
+ e.dt > 0 ? Math.hypot(e.dx, e.dy) / e.dt : 0
118
+ );
119
+ const pressure = this._mouseEvents.map(e => e.pressure);
120
+ const angJerk = _computeAngularJerk(this._mouseEvents);
121
+
122
+ const mouseStats = {
123
+ sampleCount: iei.length,
124
+ ieiMean: _mean(iei),
125
+ ieiCV: _cv(iei),
126
+ velocityP50: _percentile(velocities, 50),
127
+ velocityP95: _percentile(velocities, 95),
128
+ angularJerkMean: _mean(angJerk),
129
+ pressureVariance: _variance(pressure),
130
+ };
131
+
132
+ // ── Keyboard statistics ───────────────────────────────────────────────
133
+ const dwellTimes = this._keyEvents.filter(e => e.dwell != null).map(e => e.dwell);
134
+ const iki = [];
135
+ for (let i = 1; i < this._keyEvents.length; i++) {
136
+ iki.push(this._keyEvents[i].t - this._keyEvents[i - 1].t);
137
+ }
138
+
139
+ const keyStats = {
140
+ sampleCount: dwellTimes.length,
141
+ dwellMean: _mean(dwellTimes),
142
+ dwellCV: _cv(dwellTimes),
143
+ ikiMean: _mean(iki),
144
+ ikiCV: _cv(iki),
145
+ };
146
+
147
+ // ── Interference Coefficient ──────────────────────────────────────────
148
+ // Cross-correlate input event density with computation timing deviations.
149
+ // A real human on real hardware creates measurable CPU-scheduling pressure
150
+ // that perturbs the entropy probe's timing.
151
+ const interferenceCoefficient = _computeInterference(
152
+ this._mouseEvents,
153
+ this._keyEvents,
154
+ computationTimings,
155
+ );
156
+
157
+ return {
158
+ mouse: mouseStats,
159
+ keyboard: keyStats,
160
+ interferenceCoefficient,
161
+ durationMs,
162
+ hasActivity: iei.length > 5 || dwellTimes.length > 2,
163
+ };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * @typedef {object} BioSnapshot
169
+ * @property {object} mouse
170
+ * @property {object} keyboard
171
+ * @property {number} interferenceCoefficient – [−1, 1]; higher = more human
172
+ * @property {number} durationMs
173
+ * @property {boolean} hasActivity
174
+ */
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Statistical helpers (private)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ function _mean(arr) {
181
+ if (!arr.length) return 0;
182
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
183
+ }
184
+
185
+ function _variance(arr) {
186
+ if (arr.length < 2) return 0;
187
+ const m = _mean(arr);
188
+ return arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
189
+ }
190
+
191
+ function _cv(arr) {
192
+ if (!arr.length) return 0;
193
+ const m = _mean(arr);
194
+ if (m === 0) return 0;
195
+ return Math.sqrt(_variance(arr)) / Math.abs(m);
196
+ }
197
+
198
+ function _percentile(sorted, p) {
199
+ const arr = [...sorted].sort((a, b) => a - b);
200
+ if (!arr.length) return 0;
201
+ const idx = (p / 100) * (arr.length - 1);
202
+ const lo = Math.floor(idx);
203
+ const hi = Math.ceil(idx);
204
+ return arr[lo] + (arr[hi] - arr[lo]) * (idx - lo);
205
+ }
206
+
207
+ /** Angular jerk: second derivative of movement direction (radians / s²) */
208
+ function _computeAngularJerk(events) {
209
+ if (events.length < 3) return [];
210
+ const angles = [];
211
+ for (let i = 0; i < events.length; i++) {
212
+ const { dx, dy } = events[i];
213
+ angles.push(Math.atan2(dy, dx));
214
+ }
215
+ const d1 = [];
216
+ for (let i = 1; i < angles.length; i++) {
217
+ const dt = events[i].dt || 1;
218
+ d1.push((angles[i] - angles[i - 1]) / dt);
219
+ }
220
+ const d2 = [];
221
+ for (let i = 1; i < d1.length; i++) {
222
+ const dt = events[i].dt || 1;
223
+ d2.push(Math.abs((d1[i] - d1[i - 1]) / dt));
224
+ }
225
+ return d2;
226
+ }
227
+
228
+ /**
229
+ * Interference Coefficient
230
+ *
231
+ * For each computation sample, check whether an input event occurred within
232
+ * ±16 ms (one animation frame). Build two parallel series:
233
+ * X[i] = 1 if input near sample i, else 0
234
+ * Y[i] = deviation of timing[i] from mean timing
235
+ * Return the Pearson correlation between X and Y.
236
+ * A real human on real hardware produces positive correlation (input events
237
+ * cause measurable CPU scheduling perturbations).
238
+ */
239
+ function _computeInterference(mouseEvents, keyEvents, timings) {
240
+ if (!timings.length) return 0;
241
+
242
+ const allInputTimes = [
243
+ ...mouseEvents.map(e => e.t),
244
+ ...keyEvents.map(e => e.t),
245
+ ].sort((a, b) => a - b);
246
+
247
+ if (!allInputTimes.length) return 0;
248
+
249
+ const WINDOW_MS = 16;
250
+ const meanTiming = _mean(timings);
251
+
252
+ // We need absolute timestamps for the probe samples.
253
+ // We don't have them directly – use relative index spacing as a proxy.
254
+ // The entropy probe runs for ~(mean * n) ms starting at collectedAt.
255
+ // This is a statistical approximation; the exact alignment improves
256
+ // when callers pass `collectedAt` from the entropy result.
257
+ // For now we distribute samples evenly across the collection window.
258
+ const first = allInputTimes[0];
259
+ const last = allInputTimes[allInputTimes.length - 1];
260
+ const span = Math.max(last - first, 1);
261
+
262
+ const X = timings.map((_, i) => {
263
+ const tSample = first + (i / timings.length) * span;
264
+ return allInputTimes.some(t => Math.abs(t - tSample) < WINDOW_MS) ? 1 : 0;
265
+ });
266
+
267
+ const Y = timings.map(t => t - meanTiming);
268
+
269
+ return _pearson(X, Y);
270
+ }
271
+
272
+ function _pearson(X, Y) {
273
+ const n = X.length;
274
+ if (n < 2) return 0;
275
+ const mx = _mean(X);
276
+ const my = _mean(Y);
277
+ let num = 0, da = 0, db = 0;
278
+ for (let i = 0; i < n; i++) {
279
+ const a = X[i] - mx;
280
+ const b = Y[i] - my;
281
+ num += a * b;
282
+ da += a * a;
283
+ db += b * b;
284
+ }
285
+ const denom = Math.sqrt(da * db);
286
+ return denom < 1e-14 ? 0 : num / denom;
287
+ }