@svrnsec/pulse 0.7.0 → 0.9.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.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +883 -782
  3. package/SECURITY.md +27 -22
  4. package/bin/svrnsec-pulse.js +7 -7
  5. package/dist/{pulse.cjs.js → pulse.cjs} +6428 -6413
  6. package/dist/pulse.cjs.map +1 -0
  7. package/dist/pulse.esm.js +6429 -6415
  8. package/dist/pulse.esm.js.map +1 -1
  9. package/index.d.ts +949 -846
  10. package/package.json +189 -184
  11. package/pkg/pulse_core.js +174 -173
  12. package/src/analysis/audio.js +213 -213
  13. package/src/analysis/authenticityAudit.js +408 -393
  14. package/src/analysis/coherence.js +502 -502
  15. package/src/analysis/coordinatedBehavior.js +825 -804
  16. package/src/analysis/heuristic.js +428 -428
  17. package/src/analysis/jitter.js +446 -446
  18. package/src/analysis/llm.js +473 -472
  19. package/src/analysis/populationEntropy.js +404 -403
  20. package/src/analysis/provider.js +248 -248
  21. package/src/analysis/refraction.js +392 -391
  22. package/src/analysis/trustScore.js +356 -356
  23. package/src/cli/args.js +36 -36
  24. package/src/cli/commands/scan.js +192 -192
  25. package/src/cli/runner.js +157 -157
  26. package/src/collector/adaptive.js +200 -200
  27. package/src/collector/bio.js +297 -287
  28. package/src/collector/canvas.js +247 -239
  29. package/src/collector/dram.js +203 -203
  30. package/src/collector/enf.js +311 -311
  31. package/src/collector/entropy.js +195 -195
  32. package/src/collector/gpu.js +248 -245
  33. package/src/collector/idleAttestation.js +480 -480
  34. package/src/collector/sabTimer.js +189 -191
  35. package/src/errors.js +54 -0
  36. package/src/fingerprint.js +475 -475
  37. package/src/index.js +345 -342
  38. package/src/integrations/react-native.js +462 -459
  39. package/src/integrations/react.js +184 -185
  40. package/src/middleware/express.js +155 -155
  41. package/src/middleware/next.js +174 -175
  42. package/src/proof/challenge.js +249 -249
  43. package/src/proof/engagementToken.js +426 -394
  44. package/src/proof/fingerprint.js +268 -268
  45. package/src/proof/validator.js +82 -142
  46. package/src/registry/serializer.js +349 -349
  47. package/src/terminal.js +263 -263
  48. package/src/update-notifier.js +259 -264
  49. package/dist/pulse.cjs.js.map +0 -1
@@ -1,428 +1,428 @@
1
- /**
2
- * @svrnsec/pulse — Cross-Metric Heuristic Engine
3
- *
4
- * Instead of checking individual thresholds in isolation, this module looks
5
- * at the *relationships* between metrics. A sophisticated adversary can spoof
6
- * any single number. Spoofing six metrics so they remain mutually consistent
7
- * with physical laws is exponentially harder.
8
- *
9
- * Three core insights drive this engine:
10
- *
11
- * 1. Entropy-Jitter Coherence
12
- * Real silicon gets noisier as it heats up. Under sustained load, the
13
- * Quantization Entropy of the timing distribution grows because thermal
14
- * fluctuations add variance. A VM's hypervisor clock doesn't care about
15
- * guest temperature — its entropy is flat across all load phases.
16
- *
17
- * 2. Hurst-Autocorrelation Coherence
18
- * Genuine Brownian noise has Hurst ≈ 0.5 and near-zero autocorrelation
19
- * at all lags. These two values are physically linked. If they diverge —
20
- * high autocorrelation but Hurst near 0.5, or vice versa — the timings
21
- * were generated, not measured.
22
- *
23
- * 3. CV-Entropy Coherence
24
- * High variance (CV) must come from somewhere. On real hardware, high CV
25
- * means the timing distribution is spread out, which also means high
26
- * entropy. A VM that inflates CV without inflating entropy (e.g. by
27
- * adding synthetic outliers at fixed offsets) produces a coherence gap.
28
- */
29
-
30
- import { detectQuantizationEntropy } from './jitter.js';
31
-
32
- // ---------------------------------------------------------------------------
33
- // runHeuristicEngine
34
- // ---------------------------------------------------------------------------
35
-
36
- /**
37
- * @param {object} p
38
- * @param {import('./jitter.js').JitterAnalysis} p.jitter
39
- * @param {object|null} p.phases - from entropy collector
40
- * @param {object} p.autocorrelations
41
- * @returns {HeuristicReport}
42
- */
43
- export function runHeuristicEngine({ jitter, phases, autocorrelations }) {
44
- const findings = [];
45
- const bonuses = [];
46
- let penalty = 0; // accumulated penalty [0, 1]
47
- let bonus = 0; // accumulated bonus [0, 1]
48
- let hardOverride = null; // 'vm' | null — bypasses score entirely when set
49
-
50
- const stats = jitter.stats;
51
- if (!stats) return _empty();
52
-
53
- // ── 1. Entropy-Jitter Ratio (phases required) ────────────────────────────
54
- let entropyJitterRatio = null;
55
- let entropyJitterScore = 0.5; // neutral if no phased data
56
-
57
- if (phases) {
58
- entropyJitterRatio = phases.entropyJitterRatio;
59
-
60
- const coldQE = phases.cold?.qe ?? null;
61
- const hotQE = phases.hot?.qe ?? null;
62
-
63
- // ── HARD KILL: Phase trajectory mathematical contradiction ──────────────
64
- //
65
- // EJR is defined as: entropyJitterRatio = hot_QE / cold_QE
66
- //
67
- // Before trusting any EJR value, verify it is internally consistent with
68
- // the QE measurements it purports to summarise. Two forgery vectors exist:
69
- //
70
- // Attack A — EJR field overwritten independently:
71
- // Attacker sets entropyJitterRatio = 1.15 to claim thermal growth,
72
- // but leaves cold_QE = 3.50, hot_QE = 3.00 unchanged.
73
- // Computed EJR = 3.00 / 3.50 = 0.857.
74
- // Discrepancy 1.15 − 0.857 = 0.293 >> 0.005 tolerance → HARD KILL.
75
- //
76
- // Attack B — QE values also faked but left inconsistent:
77
- // Attacker overwrites both QE fields carelessly: cold_QE = 3.5,
78
- // hot_QE = 3.0, but EJR = 1.15 is still written.
79
- // cold_QE ≥ hot_QE with EJR ≥ 1.08 is a mathematical impossibility —
80
- // if hot ≤ cold then hot/cold ≤ 1.0, which can never be ≥ 1.08.
81
- //
82
- // Tolerance of 0.005 accounts for floating-point rounding in the entropy
83
- // collector (detectQuantizationEntropy uses discrete histogram bins).
84
- //
85
- // When HARD KILL fires:
86
- // • hardOverride = 'vm' — fingerprint.js short-circuits isSynthetic
87
- // • entropyJitterScore = 0.0 — no EJR contribution to stage-2 bonus
88
- // • penalty += 1.0 — overwhelms the physical floor cap
89
- // • No further EJR evaluation runs (the data cannot be trusted)
90
- // • The physical floor protection is explicitly bypassed (see aggregate)
91
-
92
- if (coldQE !== null && hotQE !== null) {
93
- const computedEJR = coldQE > 0 ? hotQE / coldQE : null;
94
- const fieldTampered = computedEJR !== null &&
95
- Math.abs(entropyJitterRatio - computedEJR) > 0.005;
96
- const qeContradicts = entropyJitterRatio >= 1.08 && coldQE >= hotQE;
97
-
98
- if (fieldTampered || qeContradicts) {
99
- hardOverride = 'vm';
100
- entropyJitterScore = 0.0;
101
- findings.push({
102
- id: 'EJR_PHASE_HARD_KILL',
103
- label: fieldTampered
104
- ? 'HARD KILL: stored EJR is inconsistent with cold/hot QE values — phase data tampered'
105
- : 'HARD KILL: EJR ≥ 1.08 claims entropy growth but cold_QE ≥ hot_QE — physically impossible',
106
- detail: `ejr_stored=${entropyJitterRatio.toFixed(4)} ` +
107
- `ejr_computed=${computedEJR?.toFixed(4) ?? 'n/a'} ` +
108
- `cold_QE=${coldQE.toFixed(4)} hot_QE=${hotQE.toFixed(4)} ` +
109
- `delta=${computedEJR != null ? Math.abs(entropyJitterRatio - computedEJR).toFixed(4) : 'n/a'}`,
110
- severity: 'critical',
111
- penalty: 1.0,
112
- });
113
- penalty += 1.0;
114
-
115
- } else {
116
- // QE values confirmed consistent — proceed with normal EJR evaluation.
117
- _evaluateEJR(entropyJitterRatio, coldQE, hotQE, findings, bonuses,
118
- (p) => { penalty += p; }, (b) => { bonus += b; },
119
- (s) => { entropyJitterScore = s; });
120
- }
121
-
122
- } else {
123
- // QE values unavailable — evaluate EJR ratio in isolation.
124
- _evaluateEJR(entropyJitterRatio, null, null, findings, bonuses,
125
- (p) => { penalty += p; }, (b) => { bonus += b; },
126
- (s) => { entropyJitterScore = s; });
127
- }
128
-
129
- // Phase mean drift: real CPU heats up → iterations get slower.
130
- // Only apply if hard kill wasn't already triggered.
131
- if (!hardOverride) {
132
- const coldToHotDrift = (phases.hot?.mean ?? 0) - (phases.cold?.mean ?? 0);
133
- if (coldToHotDrift > 0.05) {
134
- bonuses.push({
135
- id: 'THERMAL_DRIFT_CONFIRMED',
136
- label: 'CPU mean timing increased from cold to hot phase (thermal drift)',
137
- detail: `cold=${phases.cold.mean.toFixed(3)}ms hot=${phases.hot.mean.toFixed(3)}ms Δ=${coldToHotDrift.toFixed(3)}ms`,
138
- value: 0.08,
139
- });
140
- bonus += 0.08;
141
- }
142
- }
143
- }
144
-
145
- // ── 2. Hurst-Autocorrelation Coherence ───────────────────────────────────
146
- const h = jitter.hurstExponent ?? 0.5;
147
- const ac1 = Math.abs(autocorrelations?.lag1 ?? 0);
148
- const ac5 = Math.abs(autocorrelations?.lag5 ?? 0);
149
- const ac50 = Math.abs(autocorrelations?.lag50 ?? 0);
150
-
151
- // Physical law: Brownian noise (H≈0.5) must have low autocorrelation.
152
- // Divergence between these two means the data wasn't generated by physics.
153
- const hurstExpectedAC = Math.abs(2 * h - 1); // theoretical max |autocorr| for given H
154
- const actualAC = (ac1 + ac5) / 2;
155
- const acHurstDivergence = Math.abs(actualAC - hurstExpectedAC);
156
-
157
- if (acHurstDivergence > 0.35) {
158
- findings.push({
159
- id: 'HURST_AUTOCORR_INCOHERENT',
160
- label: 'Hurst exponent and autocorrelation are physically inconsistent',
161
- detail: `H=${h.toFixed(3)} expected_AC≈${hurstExpectedAC.toFixed(3)} actual_AC=${actualAC.toFixed(3)} divergence=${acHurstDivergence.toFixed(3)}`,
162
- severity: 'high',
163
- penalty: 0.12,
164
- });
165
- penalty += 0.12;
166
- } else if (h > 0.45 && h < 0.55 && ac1 < 0.15) {
167
- // Ideal Brownian + low autocorr — physically coherent
168
- bonuses.push({
169
- id: 'BROWNIAN_COHERENCE_CONFIRMED',
170
- label: 'Hurst ≈ 0.5 and autocorrelation near zero — genuine Brownian noise',
171
- detail: `H=${h.toFixed(3)} lag1_AC=${ac1.toFixed(3)}`,
172
- value: 0.10,
173
- });
174
- bonus += 0.10;
175
- }
176
-
177
- // ── 3. CV-Entropy Coherence ───────────────────────────────────────────────
178
- // High CV should correlate with high QE. If CV is high but QE is low,
179
- // the variance was added artificially (fixed-offset outliers, synthetic spikes).
180
- const cv = stats.cv;
181
- const qe = jitter.quantizationEntropy;
182
-
183
- // Expected QE given CV, assuming roughly normal distribution
184
- // Normal dist with σ/μ = CV: entropy ≈ log2(σ * sqrt(2πe)) + log2(n/binWidth)
185
- // We use a simplified linear proxy calibrated against real benchmarks.
186
- const expectedQE = Math.max(0, 1.5 + cv * 16); // empirical: CV=0.15 → QE≈3.9
187
- const qeDivergence = expectedQE - qe; // positive = QE lower than expected
188
-
189
- if (qeDivergence > 1.8 && cv > 0.05) {
190
- // High variance but low entropy: synthetic outliers at fixed offsets
191
- findings.push({
192
- id: 'CV_ENTROPY_INCOHERENT',
193
- label: 'High CV but low entropy — variance appears synthetic (fixed-offset outliers)',
194
- detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} bits expected_QE≈${expectedQE.toFixed(3)} gap=${qeDivergence.toFixed(3)}`,
195
- severity: 'high',
196
- penalty: 0.10,
197
- });
198
- penalty += 0.10;
199
- } else if (qeDivergence < 0.5 && cv > 0.08) {
200
- // CV and QE are coherent — timings come from a real distribution
201
- bonuses.push({
202
- id: 'CV_ENTROPY_COHERENT',
203
- label: 'Variance and entropy are physically coherent',
204
- detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} expected≈${expectedQE.toFixed(3)}`,
205
- value: 0.06,
206
- });
207
- bonus += 0.06;
208
- }
209
-
210
- // ── 4. Steal-time periodicity (the "Picket Fence" detector) ─────────────
211
- // VM steal-time bursts create a periodic signal in the autocorrelation.
212
- // If lag-50 autocorrelation is significantly higher than lag-5,
213
- // the scheduler quantum is approximately 50× the mean iteration time.
214
- const picketFence = _detectPicketFence(autocorrelations);
215
- if (picketFence.detected) {
216
- findings.push({
217
- id: 'PICKET_FENCE_DETECTED',
218
- label: `"Picket Fence" steal-time rhythm detected at lag ${picketFence.dominantLag}`,
219
- detail: picketFence.detail,
220
- severity: 'high',
221
- penalty: 0.08,
222
- });
223
- penalty += 0.08;
224
- }
225
-
226
- // ── 5. Skewness-Kurtosis coherence ───────────────────────────────────────
227
- // Real hardware timing is right-skewed (occasional slow outliers from OS preemption).
228
- // VMs that add synthetic outliers at fixed offsets produce wrong skew/kurtosis.
229
- const skew = stats.skewness ?? 0;
230
- const kurt = stats.kurtosis ?? 0;
231
-
232
- if (skew > 0.3 && kurt > 0) {
233
- // Right-skewed, leptokurtic — consistent with OS preemption on real hardware
234
- bonuses.push({
235
- id: 'NATURAL_SKEW_CONFIRMED',
236
- label: 'Right-skewed distribution with positive kurtosis — OS preemption pattern',
237
- detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
238
- value: 0.06,
239
- });
240
- bonus += 0.06;
241
- } else if (skew < 0 && Math.abs(kurt) > 1) {
242
- // Negative skew with high kurtosis: inconsistent with physical timing noise
243
- findings.push({
244
- id: 'SKEW_KURTOSIS_ANOMALY',
245
- label: 'Left-skewed distribution — inconsistent with natural hardware timing',
246
- detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
247
- severity: 'medium',
248
- penalty: 0.06,
249
- });
250
- penalty += 0.06;
251
- }
252
-
253
- // ── Physical floor protection (anti-compounding) ──────────────────────────
254
- // When the three PRIMARY timing metrics are clearly consistent with real
255
- // silicon, cap the penalty so that marginal secondary signals (weak Picket
256
- // Fence, mild EJR, slight skew anomaly) cannot compound into a rejection.
257
- //
258
- // Why: a modern i7 laptop running heavy browser extensions may show:
259
- // EJR = 1.01 → -0.10 penalty (just under the 1.02 threshold)
260
- // lag50 = 0.31 → picket fence → -0.08 penalty (background process rhythm)
261
- // slight negative skew → -0.06 penalty
262
- // total: -0.24, drops score from 0.73 → 0.49 → wrongly flagged as synthetic
263
- //
264
- // Solution: if ≥ 2 of the 3 primary metrics are unambiguously physical,
265
- // treat the device as "probably physical with some noise" and limit the
266
- // penalty to 0.22 (enough to lower confidence but not enough to reject).
267
- const clearQE = jitter.quantizationEntropy > 3.2;
268
- const clearCV = stats.cv >= 0.05 && stats.cv <= 0.30;
269
- const clearLag1 = Math.abs(autocorrelations?.lag1 ?? 1) < 0.22;
270
- const clearPhysicalCount = [clearQE, clearCV, clearLag1].filter(Boolean).length;
271
-
272
- // Also check: if at least one metric is a HARD VM indicator (QE < 2.0 or
273
- // lag1 > 0.65), override the floor — the floor is for borderline noise, not
274
- // for devices that are clearly VMs on at least one axis.
275
- const hardVmSignal =
276
- jitter.quantizationEntropy < 2.0 ||
277
- Math.abs(autocorrelations?.lag1 ?? 0) > 0.65;
278
-
279
- const penaltyCap = (!hardVmSignal && clearPhysicalCount >= 2)
280
- ? 0.22 // physical floor: cap compounding for clearly physical devices
281
- : 0.60; // default: full penalty range for ambiguous or VM-like signals
282
-
283
- // HARD KILL overrides the physical floor protection entirely.
284
- // The floor was designed to protect legitimate hardware with multiple
285
- // marginal-but-honest signals — it must never shelter a forged proof.
286
- const totalPenalty = hardOverride === 'vm'
287
- ? Math.min(1.0, penalty) // hard kill: uncapped, overwhelms all bonuses
288
- : Math.min(penaltyCap, penalty); // normal: apply floor protection
289
-
290
- // When a hard kill is active, strip all bonuses — they were earned on
291
- // data that has been proved untrustworthy.
292
- const totalBonus = hardOverride === 'vm' ? 0 : Math.min(0.35, bonus);
293
-
294
- return {
295
- penalty: totalPenalty,
296
- bonus: totalBonus,
297
- netAdjustment: totalBonus - totalPenalty,
298
- findings,
299
- bonuses: hardOverride === 'vm' ? [] : bonuses,
300
- entropyJitterRatio,
301
- entropyJitterScore,
302
- picketFence,
303
- hardOverride,
304
- coherenceFlags: findings.map(f => f.id),
305
- };
306
- }
307
-
308
- /**
309
- * @typedef {object} HeuristicReport
310
- * @property {number} penalty - total score penalty [0, 1.0]
311
- * @property {number} bonus - total score bonus [0, 0.35]
312
- * @property {number} netAdjustment - bonus - penalty
313
- * @property {object[]} findings - detected anomalies
314
- * @property {object[]} bonuses - confirmed physical properties
315
- * @property {number|null} entropyJitterRatio
316
- * @property {'vm'|null} hardOverride - set when a mathematical impossibility is detected
317
- * @property {object} picketFence
318
- * @property {string[]} coherenceFlags
319
- */
320
-
321
- // ---------------------------------------------------------------------------
322
- // EJR evaluation helper (extracted so it can run with or without QE values)
323
- // ---------------------------------------------------------------------------
324
-
325
- /**
326
- * Applies the normal EJR classification logic (called only after the hard-kill
327
- * check passes, meaning the EJR value has been verified as consistent).
328
- */
329
- function _evaluateEJR(ejr, coldQE, hotQE, findings, bonuses, addPenalty, addBonus, setScore) {
330
- const qeDetail = coldQE != null
331
- ? `cold_QE=${coldQE.toFixed(3)} hot_QE=${hotQE.toFixed(3)}`
332
- : '';
333
-
334
- if (ejr >= 1.08) {
335
- setScore(1.0);
336
- bonuses.push({
337
- id: 'ENTROPY_GROWS_WITH_LOAD',
338
- label: 'Entropy grew under load — thermal feedback confirmed',
339
- detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
340
- value: 0.12,
341
- });
342
- addBonus(0.12);
343
-
344
- } else if (ejr >= 1.02) {
345
- setScore(0.7);
346
- findings.push({
347
- id: 'ENTROPY_MILD_GROWTH',
348
- label: 'Weak entropy growth under load',
349
- detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
350
- severity: 'info',
351
- penalty: 0,
352
- });
353
-
354
- } else if (ejr > 0.95) {
355
- // Flat entropy — hypervisor clock unresponsive to guest load
356
- setScore(0.2);
357
- findings.push({
358
- id: 'ENTROPY_FLAT_UNDER_LOAD',
359
- label: 'Entropy did not grow under load — hypervisor clock suspected',
360
- detail: `ratio=${ejr.toFixed(3)} (expected ≥ 1.08 for real hardware) ${qeDetail}`,
361
- severity: 'high',
362
- penalty: 0.10,
363
- });
364
- addPenalty(0.10);
365
-
366
- } else {
367
- // Entropy DECREASED — hypervisor clock rounding became more aggressive
368
- setScore(0.0);
369
- findings.push({
370
- id: 'ENTROPY_DECREASES_UNDER_LOAD',
371
- label: 'Entropy shrank under load — hypervisor clock-rounding confirmed',
372
- detail: `ratio=${ejr.toFixed(3)} (clock rounding more aggressive at high load) ${qeDetail}`,
373
- severity: 'critical',
374
- penalty: 0.18,
375
- });
376
- addPenalty(0.18);
377
- }
378
- }
379
-
380
- // ---------------------------------------------------------------------------
381
- // Picket Fence detector
382
- // ---------------------------------------------------------------------------
383
-
384
- /**
385
- * Detects periodic steal-time bursts by finding the lag with the highest
386
- * autocorrelation beyond lag-5. A strong periodic peak indicates the
387
- * hypervisor is scheduling the guest on a fixed quantum.
388
- *
389
- * Named "Picket Fence" because of how the timing histogram looks: dense
390
- * clusters at fixed intervals with empty space between them — like fence posts.
391
- */
392
- function _detectPicketFence(autocorrelations) {
393
- const longLags = [10, 25, 50].map(l => ({
394
- lag: l,
395
- ac: Math.abs(autocorrelations?.[`lag${l}`] ?? 0),
396
- }));
397
-
398
- const shortBaseline = (Math.abs(autocorrelations?.lag5 ?? 0) +
399
- Math.abs(autocorrelations?.lag3 ?? 0)) / 2;
400
-
401
- const peak = longLags.reduce((best, cur) =>
402
- cur.ac > best.ac ? cur : best, { lag: 0, ac: 0 });
403
-
404
- // "Picket fence" condition: a long-lag autocorr significantly exceeds baseline
405
- if (peak.ac > 0.30 && peak.ac > shortBaseline + 0.20) {
406
- return {
407
- detected: true,
408
- dominantLag: peak.lag,
409
- peakAC: peak.ac,
410
- baseline: shortBaseline,
411
- detail: `lag${peak.lag}_AC=${peak.ac.toFixed(3)} baseline_AC=${shortBaseline.toFixed(3)} ` +
412
- `estimated_quantum≈${(peak.lag * 5).toFixed(0)}ms (at 5ms/iter)`,
413
- };
414
- }
415
-
416
- return { detected: false, dominantLag: null, peakAC: peak.ac, baseline: shortBaseline, detail: '' };
417
- }
418
-
419
- function _empty() {
420
- return {
421
- penalty: 0, bonus: 0, netAdjustment: 0,
422
- findings: [], bonuses: [],
423
- entropyJitterRatio: null, entropyJitterScore: 0.5,
424
- hardOverride: null,
425
- picketFence: { detected: false },
426
- coherenceFlags: [],
427
- };
428
- }
1
+ /**
2
+ * @svrnsec/pulse — Cross-Metric Heuristic Engine
3
+ *
4
+ * Instead of checking individual thresholds in isolation, this module looks
5
+ * at the *relationships* between metrics. A sophisticated adversary can spoof
6
+ * any single number. Spoofing six metrics so they remain mutually consistent
7
+ * with physical laws is exponentially harder.
8
+ *
9
+ * Three core insights drive this engine:
10
+ *
11
+ * 1. Entropy-Jitter Coherence
12
+ * Real silicon gets noisier as it heats up. Under sustained load, the
13
+ * Quantization Entropy of the timing distribution grows because thermal
14
+ * fluctuations add variance. A VM's hypervisor clock doesn't care about
15
+ * guest temperature — its entropy is flat across all load phases.
16
+ *
17
+ * 2. Hurst-Autocorrelation Coherence
18
+ * Genuine Brownian noise has Hurst ≈ 0.5 and near-zero autocorrelation
19
+ * at all lags. These two values are physically linked. If they diverge —
20
+ * high autocorrelation but Hurst near 0.5, or vice versa — the timings
21
+ * were generated, not measured.
22
+ *
23
+ * 3. CV-Entropy Coherence
24
+ * High variance (CV) must come from somewhere. On real hardware, high CV
25
+ * means the timing distribution is spread out, which also means high
26
+ * entropy. A VM that inflates CV without inflating entropy (e.g. by
27
+ * adding synthetic outliers at fixed offsets) produces a coherence gap.
28
+ */
29
+
30
+ import { detectQuantizationEntropy } from './jitter.js';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // runHeuristicEngine
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * @param {object} p
38
+ * @param {import('./jitter.js').JitterAnalysis} p.jitter
39
+ * @param {object|null} p.phases - from entropy collector
40
+ * @param {object} p.autocorrelations
41
+ * @returns {HeuristicReport}
42
+ */
43
+ export function runHeuristicEngine({ jitter, phases, autocorrelations }) {
44
+ const findings = [];
45
+ const bonuses = [];
46
+ let penalty = 0; // accumulated penalty [0, 1]
47
+ let bonus = 0; // accumulated bonus [0, 1]
48
+ let hardOverride = null; // 'vm' | null — bypasses score entirely when set
49
+
50
+ const stats = jitter.stats;
51
+ if (!stats) return _empty();
52
+
53
+ // ── 1. Entropy-Jitter Ratio (phases required) ────────────────────────────
54
+ let entropyJitterRatio = null;
55
+ let entropyJitterScore = 0.5; // neutral if no phased data
56
+
57
+ if (phases) {
58
+ entropyJitterRatio = phases.entropyJitterRatio;
59
+
60
+ const coldQE = phases.cold?.qe ?? null;
61
+ const hotQE = phases.hot?.qe ?? null;
62
+
63
+ // ── HARD KILL: Phase trajectory mathematical contradiction ──────────────
64
+ //
65
+ // EJR is defined as: entropyJitterRatio = hot_QE / cold_QE
66
+ //
67
+ // Before trusting any EJR value, verify it is internally consistent with
68
+ // the QE measurements it purports to summarise. Two forgery vectors exist:
69
+ //
70
+ // Attack A — EJR field overwritten independently:
71
+ // Attacker sets entropyJitterRatio = 1.15 to claim thermal growth,
72
+ // but leaves cold_QE = 3.50, hot_QE = 3.00 unchanged.
73
+ // Computed EJR = 3.00 / 3.50 = 0.857.
74
+ // Discrepancy 1.15 − 0.857 = 0.293 >> 0.005 tolerance → HARD KILL.
75
+ //
76
+ // Attack B — QE values also faked but left inconsistent:
77
+ // Attacker overwrites both QE fields carelessly: cold_QE = 3.5,
78
+ // hot_QE = 3.0, but EJR = 1.15 is still written.
79
+ // cold_QE ≥ hot_QE with EJR ≥ 1.08 is a mathematical impossibility —
80
+ // if hot ≤ cold then hot/cold ≤ 1.0, which can never be ≥ 1.08.
81
+ //
82
+ // Tolerance of 0.005 accounts for floating-point rounding in the entropy
83
+ // collector (detectQuantizationEntropy uses discrete histogram bins).
84
+ //
85
+ // When HARD KILL fires:
86
+ // • hardOverride = 'vm' — fingerprint.js short-circuits isSynthetic
87
+ // • entropyJitterScore = 0.0 — no EJR contribution to stage-2 bonus
88
+ // • penalty += 1.0 — overwhelms the physical floor cap
89
+ // • No further EJR evaluation runs (the data cannot be trusted)
90
+ // • The physical floor protection is explicitly bypassed (see aggregate)
91
+
92
+ if (coldQE !== null && hotQE !== null) {
93
+ const computedEJR = coldQE > 0 ? hotQE / coldQE : null;
94
+ const fieldTampered = computedEJR !== null &&
95
+ Math.abs(entropyJitterRatio - computedEJR) > 0.005;
96
+ const qeContradicts = entropyJitterRatio >= 1.08 && coldQE >= hotQE;
97
+
98
+ if (fieldTampered || qeContradicts) {
99
+ hardOverride = 'vm';
100
+ entropyJitterScore = 0.0;
101
+ findings.push({
102
+ id: 'EJR_PHASE_HARD_KILL',
103
+ label: fieldTampered
104
+ ? 'HARD KILL: stored EJR is inconsistent with cold/hot QE values — phase data tampered'
105
+ : 'HARD KILL: EJR ≥ 1.08 claims entropy growth but cold_QE ≥ hot_QE — physically impossible',
106
+ detail: `ejr_stored=${entropyJitterRatio.toFixed(4)} ` +
107
+ `ejr_computed=${computedEJR?.toFixed(4) ?? 'n/a'} ` +
108
+ `cold_QE=${coldQE.toFixed(4)} hot_QE=${hotQE.toFixed(4)} ` +
109
+ `delta=${computedEJR != null ? Math.abs(entropyJitterRatio - computedEJR).toFixed(4) : 'n/a'}`,
110
+ severity: 'critical',
111
+ penalty: 1.0,
112
+ });
113
+ penalty += 1.0;
114
+
115
+ } else {
116
+ // QE values confirmed consistent — proceed with normal EJR evaluation.
117
+ _evaluateEJR(entropyJitterRatio, coldQE, hotQE, findings, bonuses,
118
+ (p) => { penalty += p; }, (b) => { bonus += b; },
119
+ (s) => { entropyJitterScore = s; });
120
+ }
121
+
122
+ } else {
123
+ // QE values unavailable — evaluate EJR ratio in isolation.
124
+ _evaluateEJR(entropyJitterRatio, null, null, findings, bonuses,
125
+ (p) => { penalty += p; }, (b) => { bonus += b; },
126
+ (s) => { entropyJitterScore = s; });
127
+ }
128
+
129
+ // Phase mean drift: real CPU heats up → iterations get slower.
130
+ // Only apply if hard kill wasn't already triggered.
131
+ if (!hardOverride) {
132
+ const coldToHotDrift = (phases.hot?.mean ?? 0) - (phases.cold?.mean ?? 0);
133
+ if (coldToHotDrift > 0.05) {
134
+ bonuses.push({
135
+ id: 'THERMAL_DRIFT_CONFIRMED',
136
+ label: 'CPU mean timing increased from cold to hot phase (thermal drift)',
137
+ detail: `cold=${phases.cold.mean.toFixed(3)}ms hot=${phases.hot.mean.toFixed(3)}ms Δ=${coldToHotDrift.toFixed(3)}ms`,
138
+ value: 0.08,
139
+ });
140
+ bonus += 0.08;
141
+ }
142
+ }
143
+ }
144
+
145
+ // ── 2. Hurst-Autocorrelation Coherence ───────────────────────────────────
146
+ const h = jitter.hurstExponent ?? 0.5;
147
+ const ac1 = Math.abs(autocorrelations?.lag1 ?? 0);
148
+ const ac5 = Math.abs(autocorrelations?.lag5 ?? 0);
149
+ const ac50 = Math.abs(autocorrelations?.lag50 ?? 0);
150
+
151
+ // Physical law: Brownian noise (H≈0.5) must have low autocorrelation.
152
+ // Divergence between these two means the data wasn't generated by physics.
153
+ const hurstExpectedAC = Math.abs(2 * h - 1); // theoretical max |autocorr| for given H
154
+ const actualAC = (ac1 + ac5) / 2;
155
+ const acHurstDivergence = Math.abs(actualAC - hurstExpectedAC);
156
+
157
+ if (acHurstDivergence > 0.35) {
158
+ findings.push({
159
+ id: 'HURST_AUTOCORR_INCOHERENT',
160
+ label: 'Hurst exponent and autocorrelation are physically inconsistent',
161
+ detail: `H=${h.toFixed(3)} expected_AC≈${hurstExpectedAC.toFixed(3)} actual_AC=${actualAC.toFixed(3)} divergence=${acHurstDivergence.toFixed(3)}`,
162
+ severity: 'high',
163
+ penalty: 0.12,
164
+ });
165
+ penalty += 0.12;
166
+ } else if (h > 0.45 && h < 0.55 && ac1 < 0.15) {
167
+ // Ideal Brownian + low autocorr — physically coherent
168
+ bonuses.push({
169
+ id: 'BROWNIAN_COHERENCE_CONFIRMED',
170
+ label: 'Hurst ≈ 0.5 and autocorrelation near zero — genuine Brownian noise',
171
+ detail: `H=${h.toFixed(3)} lag1_AC=${ac1.toFixed(3)}`,
172
+ value: 0.10,
173
+ });
174
+ bonus += 0.10;
175
+ }
176
+
177
+ // ── 3. CV-Entropy Coherence ───────────────────────────────────────────────
178
+ // High CV should correlate with high QE. If CV is high but QE is low,
179
+ // the variance was added artificially (fixed-offset outliers, synthetic spikes).
180
+ const cv = stats.cv;
181
+ const qe = jitter.quantizationEntropy;
182
+
183
+ // Expected QE given CV, assuming roughly normal distribution
184
+ // Normal dist with σ/μ = CV: entropy ≈ log2(σ * sqrt(2πe)) + log2(n/binWidth)
185
+ // We use a simplified linear proxy calibrated against real benchmarks.
186
+ const expectedQE = Math.max(0, 1.5 + cv * 16); // empirical: CV=0.15 → QE≈3.9
187
+ const qeDivergence = expectedQE - qe; // positive = QE lower than expected
188
+
189
+ if (qeDivergence > 1.8 && cv > 0.05) {
190
+ // High variance but low entropy: synthetic outliers at fixed offsets
191
+ findings.push({
192
+ id: 'CV_ENTROPY_INCOHERENT',
193
+ label: 'High CV but low entropy — variance appears synthetic (fixed-offset outliers)',
194
+ detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} bits expected_QE≈${expectedQE.toFixed(3)} gap=${qeDivergence.toFixed(3)}`,
195
+ severity: 'high',
196
+ penalty: 0.10,
197
+ });
198
+ penalty += 0.10;
199
+ } else if (qeDivergence < 0.5 && cv > 0.08) {
200
+ // CV and QE are coherent — timings come from a real distribution
201
+ bonuses.push({
202
+ id: 'CV_ENTROPY_COHERENT',
203
+ label: 'Variance and entropy are physically coherent',
204
+ detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} expected≈${expectedQE.toFixed(3)}`,
205
+ value: 0.06,
206
+ });
207
+ bonus += 0.06;
208
+ }
209
+
210
+ // ── 4. Steal-time periodicity (the "Picket Fence" detector) ─────────────
211
+ // VM steal-time bursts create a periodic signal in the autocorrelation.
212
+ // If lag-50 autocorrelation is significantly higher than lag-5,
213
+ // the scheduler quantum is approximately 50× the mean iteration time.
214
+ const picketFence = _detectPicketFence(autocorrelations);
215
+ if (picketFence.detected) {
216
+ findings.push({
217
+ id: 'PICKET_FENCE_DETECTED',
218
+ label: `"Picket Fence" steal-time rhythm detected at lag ${picketFence.dominantLag}`,
219
+ detail: picketFence.detail,
220
+ severity: 'high',
221
+ penalty: 0.08,
222
+ });
223
+ penalty += 0.08;
224
+ }
225
+
226
+ // ── 5. Skewness-Kurtosis coherence ───────────────────────────────────────
227
+ // Real hardware timing is right-skewed (occasional slow outliers from OS preemption).
228
+ // VMs that add synthetic outliers at fixed offsets produce wrong skew/kurtosis.
229
+ const skew = stats.skewness ?? 0;
230
+ const kurt = stats.kurtosis ?? 0;
231
+
232
+ if (skew > 0.3 && kurt > 0) {
233
+ // Right-skewed, leptokurtic — consistent with OS preemption on real hardware
234
+ bonuses.push({
235
+ id: 'NATURAL_SKEW_CONFIRMED',
236
+ label: 'Right-skewed distribution with positive kurtosis — OS preemption pattern',
237
+ detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
238
+ value: 0.06,
239
+ });
240
+ bonus += 0.06;
241
+ } else if (skew < 0 && Math.abs(kurt) > 1) {
242
+ // Negative skew with high kurtosis: inconsistent with physical timing noise
243
+ findings.push({
244
+ id: 'SKEW_KURTOSIS_ANOMALY',
245
+ label: 'Left-skewed distribution — inconsistent with natural hardware timing',
246
+ detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
247
+ severity: 'medium',
248
+ penalty: 0.06,
249
+ });
250
+ penalty += 0.06;
251
+ }
252
+
253
+ // ── Physical floor protection (anti-compounding) ──────────────────────────
254
+ // When the three PRIMARY timing metrics are clearly consistent with real
255
+ // silicon, cap the penalty so that marginal secondary signals (weak Picket
256
+ // Fence, mild EJR, slight skew anomaly) cannot compound into a rejection.
257
+ //
258
+ // Why: a modern i7 laptop running heavy browser extensions may show:
259
+ // EJR = 1.01 → -0.10 penalty (just under the 1.02 threshold)
260
+ // lag50 = 0.31 → picket fence → -0.08 penalty (background process rhythm)
261
+ // slight negative skew → -0.06 penalty
262
+ // total: -0.24, drops score from 0.73 → 0.49 → wrongly flagged as synthetic
263
+ //
264
+ // Solution: if ≥ 2 of the 3 primary metrics are unambiguously physical,
265
+ // treat the device as "probably physical with some noise" and limit the
266
+ // penalty to 0.22 (enough to lower confidence but not enough to reject).
267
+ const clearQE = jitter.quantizationEntropy > 3.2;
268
+ const clearCV = stats.cv >= 0.05 && stats.cv <= 0.30;
269
+ const clearLag1 = Math.abs(autocorrelations?.lag1 ?? 1) < 0.22;
270
+ const clearPhysicalCount = [clearQE, clearCV, clearLag1].filter(Boolean).length;
271
+
272
+ // Also check: if at least one metric is a HARD VM indicator (QE < 2.0 or
273
+ // lag1 > 0.65), override the floor — the floor is for borderline noise, not
274
+ // for devices that are clearly VMs on at least one axis.
275
+ const hardVmSignal =
276
+ jitter.quantizationEntropy < 2.0 ||
277
+ Math.abs(autocorrelations?.lag1 ?? 0) > 0.65;
278
+
279
+ const penaltyCap = (!hardVmSignal && clearPhysicalCount >= 2)
280
+ ? 0.22 // physical floor: cap compounding for clearly physical devices
281
+ : 0.60; // default: full penalty range for ambiguous or VM-like signals
282
+
283
+ // HARD KILL overrides the physical floor protection entirely.
284
+ // The floor was designed to protect legitimate hardware with multiple
285
+ // marginal-but-honest signals — it must never shelter a forged proof.
286
+ const totalPenalty = hardOverride === 'vm'
287
+ ? Math.min(1.0, penalty) // hard kill: uncapped, overwhelms all bonuses
288
+ : Math.min(penaltyCap, penalty); // normal: apply floor protection
289
+
290
+ // When a hard kill is active, strip all bonuses — they were earned on
291
+ // data that has been proved untrustworthy.
292
+ const totalBonus = hardOverride === 'vm' ? 0 : Math.min(0.35, bonus);
293
+
294
+ return {
295
+ penalty: totalPenalty,
296
+ bonus: totalBonus,
297
+ netAdjustment: totalBonus - totalPenalty,
298
+ findings,
299
+ bonuses: hardOverride === 'vm' ? [] : bonuses,
300
+ entropyJitterRatio,
301
+ entropyJitterScore,
302
+ picketFence,
303
+ hardOverride,
304
+ coherenceFlags: findings.map(f => f.id),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * @typedef {object} HeuristicReport
310
+ * @property {number} penalty - total score penalty [0, 1.0]
311
+ * @property {number} bonus - total score bonus [0, 0.35]
312
+ * @property {number} netAdjustment - bonus - penalty
313
+ * @property {object[]} findings - detected anomalies
314
+ * @property {object[]} bonuses - confirmed physical properties
315
+ * @property {number|null} entropyJitterRatio
316
+ * @property {'vm'|null} hardOverride - set when a mathematical impossibility is detected
317
+ * @property {object} picketFence
318
+ * @property {string[]} coherenceFlags
319
+ */
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // EJR evaluation helper (extracted so it can run with or without QE values)
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /**
326
+ * Applies the normal EJR classification logic (called only after the hard-kill
327
+ * check passes, meaning the EJR value has been verified as consistent).
328
+ */
329
+ function _evaluateEJR(ejr, coldQE, hotQE, findings, bonuses, addPenalty, addBonus, setScore) {
330
+ const qeDetail = coldQE != null
331
+ ? `cold_QE=${coldQE.toFixed(3)} hot_QE=${hotQE.toFixed(3)}`
332
+ : '';
333
+
334
+ if (ejr >= 1.08) {
335
+ setScore(1.0);
336
+ bonuses.push({
337
+ id: 'ENTROPY_GROWS_WITH_LOAD',
338
+ label: 'Entropy grew under load — thermal feedback confirmed',
339
+ detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
340
+ value: 0.12,
341
+ });
342
+ addBonus(0.12);
343
+
344
+ } else if (ejr >= 1.02) {
345
+ setScore(0.7);
346
+ findings.push({
347
+ id: 'ENTROPY_MILD_GROWTH',
348
+ label: 'Weak entropy growth under load',
349
+ detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
350
+ severity: 'info',
351
+ penalty: 0,
352
+ });
353
+
354
+ } else if (ejr > 0.95) {
355
+ // Flat entropy — hypervisor clock unresponsive to guest load
356
+ setScore(0.2);
357
+ findings.push({
358
+ id: 'ENTROPY_FLAT_UNDER_LOAD',
359
+ label: 'Entropy did not grow under load — hypervisor clock suspected',
360
+ detail: `ratio=${ejr.toFixed(3)} (expected ≥ 1.08 for real hardware) ${qeDetail}`,
361
+ severity: 'high',
362
+ penalty: 0.10,
363
+ });
364
+ addPenalty(0.10);
365
+
366
+ } else {
367
+ // Entropy DECREASED — hypervisor clock rounding became more aggressive
368
+ setScore(0.0);
369
+ findings.push({
370
+ id: 'ENTROPY_DECREASES_UNDER_LOAD',
371
+ label: 'Entropy shrank under load — hypervisor clock-rounding confirmed',
372
+ detail: `ratio=${ejr.toFixed(3)} (clock rounding more aggressive at high load) ${qeDetail}`,
373
+ severity: 'critical',
374
+ penalty: 0.18,
375
+ });
376
+ addPenalty(0.18);
377
+ }
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Picket Fence detector
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /**
385
+ * Detects periodic steal-time bursts by finding the lag with the highest
386
+ * autocorrelation beyond lag-5. A strong periodic peak indicates the
387
+ * hypervisor is scheduling the guest on a fixed quantum.
388
+ *
389
+ * Named "Picket Fence" because of how the timing histogram looks: dense
390
+ * clusters at fixed intervals with empty space between them — like fence posts.
391
+ */
392
+ function _detectPicketFence(autocorrelations) {
393
+ const longLags = [10, 25, 50].map(l => ({
394
+ lag: l,
395
+ ac: Math.abs(autocorrelations?.[`lag${l}`] ?? 0),
396
+ }));
397
+
398
+ const shortBaseline = (Math.abs(autocorrelations?.lag5 ?? 0) +
399
+ Math.abs(autocorrelations?.lag3 ?? 0)) / 2;
400
+
401
+ const peak = longLags.reduce((best, cur) =>
402
+ cur.ac > best.ac ? cur : best, { lag: 0, ac: 0 });
403
+
404
+ // "Picket fence" condition: a long-lag autocorr significantly exceeds baseline
405
+ if (peak.ac > 0.30 && peak.ac > shortBaseline + 0.20) {
406
+ return {
407
+ detected: true,
408
+ dominantLag: peak.lag,
409
+ peakAC: peak.ac,
410
+ baseline: shortBaseline,
411
+ detail: `lag${peak.lag}_AC=${peak.ac.toFixed(3)} baseline_AC=${shortBaseline.toFixed(3)} ` +
412
+ `estimated_quantum≈${(peak.lag * 5).toFixed(0)}ms (at 5ms/iter)`,
413
+ };
414
+ }
415
+
416
+ return { detected: false, dominantLag: null, peakAC: peak.ac, baseline: shortBaseline, detail: '' };
417
+ }
418
+
419
+ function _empty() {
420
+ return {
421
+ penalty: 0, bonus: 0, netAdjustment: 0,
422
+ findings: [], bonuses: [],
423
+ entropyJitterRatio: null, entropyJitterScore: 0.5,
424
+ hardOverride: null,
425
+ picketFence: { detected: false },
426
+ coherenceFlags: [],
427
+ };
428
+ }