@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,311 +1,311 @@
1
- /**
2
- * @svrnsec/pulse — Electrical Network Frequency (ENF) Detection
3
- *
4
- * ┌─────────────────────────────────────────────────────────────────────────┐
5
- * │ WHAT THIS IS │
6
- * │ │
7
- * │ Power grids operate at a nominal frequency — 60 Hz in the Americas, │
8
- * │ 50 Hz in Europe, Asia, Africa, and Australia. This frequency is not │
9
- * │ perfectly stable. It deviates by ±0.05 Hz in real time as generators │
10
- * │ spin up and down to match load. These deviations are unique, logged │
11
- * │ by grid operators, and have been used in forensics since 2010 to │
12
- * │ timestamp recordings to within seconds. │
13
- * │ │
14
- * │ We are the first to measure it from a browser. │
15
- * └─────────────────────────────────────────────────────────────────────────┘
16
- *
17
- * Signal path
18
- * ───────────
19
- * AC mains (50/60 Hz)
20
- * → ATX power supply (full-wave rectified → 100/120 Hz ripple on DC rail)
21
- * → Voltage Regulator Module (VRM) on motherboard
22
- * → CPU Vcore (supply voltage to processor dies)
23
- * → Transistor switching speed (slightly modulated by Vcore)
24
- * → Matrix multiply loop timing (measurably longer when Vcore dips)
25
- * → Our microsecond-resolution timing probe
26
- *
27
- * The ripple amplitude at the timing layer is ~10–100 ns — invisible to
28
- * performance.now() at 1 ms resolution, clearly visible with Atomics-based
29
- * microsecond timing. This is why this module depends on sabTimer.js.
30
- *
31
- * What we detect
32
- * ──────────────
33
- * gridFrequency 50.0 or 60.0 Hz (nominal), ±0.5 Hz measured
34
- * gridRegion 'americas' (60 Hz) | 'emea_apac' (50 Hz) | 'unknown'
35
- * ripplePresent true if the 100/120 Hz harmonic is statistically significant
36
- * ripplePower power of the dominant grid harmonic (0–1)
37
- * enfDeviation precise measured frequency – nominal (Hz) — temporal fingerprint
38
- * temporalHash BLAKE3 of (enfDeviation + timestamp) — attestation anchor
39
- *
40
- * What this proves
41
- * ───────────────
42
- * 1. The device is connected to a real AC power grid (rules out cloud VMs,
43
- * UPS-backed datacenter servers, and battery-only devices off-grid)
44
- * 2. The geographic grid region (50 Hz vs 60 Hz — no IP, no location API)
45
- * 3. A temporal fingerprint that can be cross-referenced against public ENF
46
- * logs (e.g., www.gridwatch.templar.linux.org.uk) to verify the session
47
- * timestamp is authentic
48
- *
49
- * Why VMs fail
50
- * ────────────
51
- * Datacenter power is conditioned, filtered, and UPS-backed. Grid frequency
52
- * deviations are removed before they reach the server. Cloud VMs receive
53
- * perfectly regulated power — the ENF signal does not exist in their timing
54
- * measurements. This is a physical property of datacenter infrastructure,
55
- * not a software configuration that can be patched or spoofed.
56
- *
57
- * A VM attempting to inject synthetic ENF ripple into its virtual clock
58
- * would need to:
59
- * 1. Know the real-time ENF of the target grid region (requires live API)
60
- * 2. Modulate the virtual TSC at sub-microsecond precision
61
- * 3. Match the precise VRM transfer function of the target motherboard
62
- * This is not a realistic attack surface.
63
- *
64
- * Battery devices
65
- * ───────────────
66
- * Laptops on battery have no AC ripple. The module detects this via absence
67
- * of both 100 Hz and 120 Hz signal, combined with very low ripple variance.
68
- * This is handled by the 'battery_or_conditioned' verdict — treated as
69
- * inconclusive rather than VM (real laptops exist).
70
- *
71
- * Required: crossOriginIsolated = true (COOP + COEP headers)
72
- * The SAB microsecond timer is required for ENF detection. On browsers where
73
- * it is unavailable, the module returns { enfAvailable: false }.
74
- */
75
-
76
- import { isSabAvailable, collectHighResTimings } from './sabTimer.js';
77
-
78
- // ── Grid frequency constants ──────────────────────────────────────────────────
79
- const GRID_60HZ_NOMINAL = 60.0; // Americas, parts of Japan & Korea
80
- const GRID_50HZ_NOMINAL = 50.0; // EMEA, APAC, most of Asia
81
- const RIPPLE_60HZ = 120.0; // Full-wave rectified: 2 × 60 Hz
82
- const RIPPLE_50HZ = 100.0; // Full-wave rectified: 2 × 50 Hz
83
- const RIPPLE_SLACK_HZ = 2.0; // ±2 Hz around nominal (accounts for VRM response)
84
- const MIN_RIPPLE_POWER = 0.04; // Minimum power ratio to declare ripple present
85
- const SNR_THRESHOLD = 2.0; // Signal-to-noise ratio for confident detection
86
-
87
- // ── Probe parameters ──────────────────────────────────────────────────────────
88
- // We need enough samples at sufficient rate to resolve 100–120 Hz.
89
- // Nyquist: sample_rate > 240 Hz (need >2× the highest target frequency).
90
- // With ~1 ms per iteration, 100 Hz ≈ 10 samples per cycle — adequate.
91
- // We want at least 20 full cycles → 200 iterations minimum.
92
- const PROBE_ITERATIONS = 512; // power of 2 for clean FFT
93
- const PROBE_MATRIX_SIZE = 16; // small matrix → ~1 ms/iter → ~500 Hz sample rate
94
-
95
- /* ─── collectEnfTimings ─────────────────────────────────────────────────────── */
96
-
97
- /**
98
- * @param {object} [opts]
99
- * @param {number} [opts.iterations=512]
100
- * @returns {Promise<EnfResult>}
101
- */
102
- export async function collectEnfTimings(opts = {}) {
103
- const { iterations = PROBE_ITERATIONS } = opts;
104
-
105
- if (!isSabAvailable()) {
106
- return _noEnf('SharedArrayBuffer not available — COOP+COEP headers required');
107
- }
108
-
109
- // Collect high-resolution CPU timing series
110
- const { timings, resolutionUs } = collectHighResTimings({
111
- iterations,
112
- matrixSize: PROBE_MATRIX_SIZE,
113
- });
114
-
115
- if (timings.length < 128) {
116
- return _noEnf('insufficient timing samples');
117
- }
118
-
119
- // Estimate the sample rate from actual timing
120
- const meanIterMs = timings.reduce((s, v) => s + v, 0) / timings.length;
121
- const sampleRateHz = meanIterMs > 0 ? 1000 / meanIterMs : 0;
122
-
123
- if (sampleRateHz < 60) {
124
- return _noEnf(`sample rate too low for ENF detection: ${sampleRateHz.toFixed(0)} Hz`);
125
- }
126
-
127
- // ── Power Spectral Density ────────────────────────────────────────────────
128
- const n = timings.length;
129
- const psd = _computePsd(timings, sampleRateHz);
130
-
131
- // Find the dominant frequency peak
132
- const peakIdx = psd.reduce((best, v, i) => v > psd[best] ? i : best, 0);
133
- const peakFreq = psd.freqs[peakIdx];
134
-
135
- // Power in 100 Hz window vs 120 Hz window
136
- const power100 = _bandPower(psd, RIPPLE_50HZ, RIPPLE_SLACK_HZ);
137
- const power120 = _bandPower(psd, RIPPLE_60HZ, RIPPLE_SLACK_HZ);
138
- const baseline = _baselinePower(psd, [
139
- [RIPPLE_50HZ - RIPPLE_SLACK_HZ, RIPPLE_50HZ + RIPPLE_SLACK_HZ],
140
- [RIPPLE_60HZ - RIPPLE_SLACK_HZ, RIPPLE_60HZ + RIPPLE_SLACK_HZ],
141
- ]);
142
-
143
- const snr100 = baseline > 0 ? power100 / baseline : 0;
144
- const snr120 = baseline > 0 ? power120 / baseline : 0;
145
-
146
- // ── Verdict ───────────────────────────────────────────────────────────────
147
- const has100 = power100 > MIN_RIPPLE_POWER && snr100 > SNR_THRESHOLD;
148
- const has120 = power120 > MIN_RIPPLE_POWER && snr120 > SNR_THRESHOLD;
149
-
150
- let gridFrequency = null;
151
- let gridRegion = 'unknown';
152
- let ripplePower = 0;
153
- let nominalHz = null;
154
-
155
- if (has120 && power120 >= power100) {
156
- gridFrequency = GRID_60HZ_NOMINAL;
157
- gridRegion = 'americas';
158
- ripplePower = power120;
159
- nominalHz = RIPPLE_60HZ;
160
- } else if (has100) {
161
- gridFrequency = GRID_50HZ_NOMINAL;
162
- gridRegion = 'emea_apac';
163
- ripplePower = power100;
164
- nominalHz = RIPPLE_50HZ;
165
- }
166
-
167
- const ripplePresent = has100 || has120;
168
-
169
- // ── ENF deviation (temporal fingerprint) ─────────────────────────────────
170
- // The precise ripple frequency deviates from nominal by ±0.1 Hz in real time.
171
- // We measure the peak frequency in the ripple band to extract this deviation.
172
- let enfDeviation = null;
173
- if (ripplePresent && nominalHz !== null) {
174
- const preciseRippleFreq = _precisePeakFreq(psd, nominalHz, RIPPLE_SLACK_HZ);
175
- enfDeviation = +(preciseRippleFreq - nominalHz).toFixed(3); // Hz deviation from nominal
176
- }
177
-
178
- // ── Verdict ───────────────────────────────────────────────────────────────
179
- const verdict =
180
- !ripplePresent ? 'no_grid_signal' // VM, UPS, or battery
181
- : gridRegion === 'americas' ? 'grid_60hz'
182
- : gridRegion === 'emea_apac' ? 'grid_50hz'
183
- : 'grid_detected_region_unknown';
184
-
185
- const isVmIndicator = !ripplePresent && sampleRateHz > 100;
186
- // High sample rate + no ripple = conditioned power (datacenter)
187
-
188
- return {
189
- enfAvailable: true,
190
- ripplePresent,
191
- gridFrequency,
192
- gridRegion,
193
- ripplePower: +ripplePower.toFixed(4),
194
- snr50hz: +snr100.toFixed(2),
195
- snr60hz: +snr120.toFixed(2),
196
- enfDeviation,
197
- sampleRateHz: +sampleRateHz.toFixed(1),
198
- resolutionUs,
199
- verdict,
200
- isVmIndicator,
201
- // For cross-referencing against public ENF databases (forensic timestamp)
202
- temporalAnchor: enfDeviation !== null ? {
203
- nominalHz,
204
- measuredRippleHz: +(nominalHz + enfDeviation).toFixed(4),
205
- capturedAt: Date.now(),
206
- // Matches format used by ENF forensic databases:
207
- // https://www.enf.cc | UK National Grid ESO data
208
- gridHz: gridFrequency,
209
- } : null,
210
- };
211
- }
212
-
213
- /* ─── Power Spectral Density (Welch-inspired DFT) ───────────────────────── */
214
-
215
- function _computePsd(signal, sampleRateHz) {
216
- const n = signal.length;
217
- const mean = signal.reduce((s, v) => s + v, 0) / n;
218
-
219
- // Remove DC offset and apply Hann window
220
- const windowed = signal.map((v, i) => {
221
- const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1))); // Hann
222
- return (v - mean) * w;
223
- });
224
-
225
- // DFT up to Nyquist — only need up to ~200 Hz so we cap bins
226
- const maxFreq = Math.min(200, sampleRateHz / 2);
227
- const maxBin = Math.floor(maxFreq * n / sampleRateHz);
228
-
229
- const powers = new Float64Array(maxBin);
230
- const freqs = new Float64Array(maxBin);
231
-
232
- for (let k = 1; k < maxBin; k++) {
233
- let re = 0, im = 0;
234
- for (let t = 0; t < n; t++) {
235
- const angle = (2 * Math.PI * k * t) / n;
236
- re += windowed[t] * Math.cos(angle);
237
- im -= windowed[t] * Math.sin(angle);
238
- }
239
- powers[k] = (re * re + im * im) / (n * n);
240
- freqs[k] = (k * sampleRateHz) / n;
241
- }
242
-
243
- // Normalise powers so they sum to 1 (makes thresholds sample-count-independent)
244
- const total = powers.reduce((s, v) => s + v, 0);
245
- if (total > 0) for (let i = 0; i < powers.length; i++) powers[i] /= total;
246
-
247
- return { powers, freqs };
248
- }
249
-
250
- function _bandPower(psd, centerHz, halfwidthHz) {
251
- let power = 0;
252
- for (let i = 0; i < psd.freqs.length; i++) {
253
- if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz) {
254
- power += psd.powers[i];
255
- }
256
- }
257
- return power;
258
- }
259
-
260
- function _baselinePower(psd, excludeBands) {
261
- let sum = 0, count = 0;
262
- for (let i = 0; i < psd.freqs.length; i++) {
263
- const f = psd.freqs[i];
264
- const excluded = excludeBands.some(([lo, hi]) => f >= lo && f <= hi);
265
- if (!excluded && f > 10 && f < 200) { sum += psd.powers[i]; count++; }
266
- }
267
- return count > 0 ? sum / count : 0;
268
- }
269
-
270
- function _precisePeakFreq(psd, centerHz, halfwidthHz) {
271
- // Quadratic interpolation around the peak bin for sub-bin precision
272
- let peakBin = 0, peakPow = -Infinity;
273
- for (let i = 0; i < psd.freqs.length; i++) {
274
- if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz && psd.powers[i] > peakPow) {
275
- peakPow = psd.powers[i]; peakBin = i;
276
- }
277
- }
278
- if (peakBin <= 0 || peakBin >= psd.powers.length - 1) return psd.freqs[peakBin];
279
-
280
- // Quadratic peak interpolation (Jacobsen method)
281
- const alpha = psd.powers[peakBin - 1];
282
- const beta = psd.powers[peakBin];
283
- const gamma = psd.powers[peakBin + 1];
284
- const denom = alpha - 2 * beta + gamma;
285
- if (Math.abs(denom) < 1e-14) return psd.freqs[peakBin];
286
- const deltaBin = 0.5 * (alpha - gamma) / denom;
287
- const binWidth = psd.freqs[1] - psd.freqs[0];
288
- return psd.freqs[peakBin] + deltaBin * binWidth;
289
- }
290
-
291
- function _noEnf(reason) {
292
- return {
293
- enfAvailable: false, ripplePresent: false, gridFrequency: null,
294
- gridRegion: 'unknown', ripplePower: 0, snr50hz: 0, snr60hz: 0,
295
- enfDeviation: null, sampleRateHz: 0, resolutionUs: 0,
296
- verdict: 'unavailable', isVmIndicator: false, temporalAnchor: null, reason,
297
- };
298
- }
299
-
300
- /**
301
- * @typedef {object} EnfResult
302
- * @property {boolean} enfAvailable
303
- * @property {boolean} ripplePresent false = VM / datacenter / battery
304
- * @property {number|null} gridFrequency 50 or 60 Hz
305
- * @property {string} gridRegion 'americas' | 'emea_apac' | 'unknown'
306
- * @property {number} ripplePower normalised PSD power at grid harmonic
307
- * @property {number|null} enfDeviation Hz deviation from nominal (temporal fingerprint)
308
- * @property {string} verdict
309
- * @property {boolean} isVmIndicator true if signal absence + high sample rate
310
- * @property {object|null} temporalAnchor forensic timestamp anchor
311
- */
1
+ /**
2
+ * @svrnsec/pulse — Electrical Network Frequency (ENF) Detection
3
+ *
4
+ * ┌─────────────────────────────────────────────────────────────────────────┐
5
+ * │ WHAT THIS IS │
6
+ * │ │
7
+ * │ Power grids operate at a nominal frequency — 60 Hz in the Americas, │
8
+ * │ 50 Hz in Europe, Asia, Africa, and Australia. This frequency is not │
9
+ * │ perfectly stable. It deviates by ±0.05 Hz in real time as generators │
10
+ * │ spin up and down to match load. These deviations are unique, logged │
11
+ * │ by grid operators, and have been used in forensics since 2010 to │
12
+ * │ timestamp recordings to within seconds. │
13
+ * │ │
14
+ * │ We are the first to measure it from a browser. │
15
+ * └─────────────────────────────────────────────────────────────────────────┘
16
+ *
17
+ * Signal path
18
+ * ───────────
19
+ * AC mains (50/60 Hz)
20
+ * → ATX power supply (full-wave rectified → 100/120 Hz ripple on DC rail)
21
+ * → Voltage Regulator Module (VRM) on motherboard
22
+ * → CPU Vcore (supply voltage to processor dies)
23
+ * → Transistor switching speed (slightly modulated by Vcore)
24
+ * → Matrix multiply loop timing (measurably longer when Vcore dips)
25
+ * → Our microsecond-resolution timing probe
26
+ *
27
+ * The ripple amplitude at the timing layer is ~10–100 ns — invisible to
28
+ * performance.now() at 1 ms resolution, clearly visible with Atomics-based
29
+ * microsecond timing. This is why this module depends on sabTimer.js.
30
+ *
31
+ * What we detect
32
+ * ──────────────
33
+ * gridFrequency 50.0 or 60.0 Hz (nominal), ±0.5 Hz measured
34
+ * gridRegion 'americas' (60 Hz) | 'emea_apac' (50 Hz) | 'unknown'
35
+ * ripplePresent true if the 100/120 Hz harmonic is statistically significant
36
+ * ripplePower power of the dominant grid harmonic (0–1)
37
+ * enfDeviation precise measured frequency – nominal (Hz) — temporal fingerprint
38
+ * temporalHash BLAKE3 of (enfDeviation + timestamp) — attestation anchor
39
+ *
40
+ * What this proves
41
+ * ───────────────
42
+ * 1. The device is connected to a real AC power grid (rules out cloud VMs,
43
+ * UPS-backed datacenter servers, and battery-only devices off-grid)
44
+ * 2. The geographic grid region (50 Hz vs 60 Hz — no IP, no location API)
45
+ * 3. A temporal fingerprint that can be cross-referenced against public ENF
46
+ * logs (e.g., www.gridwatch.templar.linux.org.uk) to verify the session
47
+ * timestamp is authentic
48
+ *
49
+ * Why VMs fail
50
+ * ────────────
51
+ * Datacenter power is conditioned, filtered, and UPS-backed. Grid frequency
52
+ * deviations are removed before they reach the server. Cloud VMs receive
53
+ * perfectly regulated power — the ENF signal does not exist in their timing
54
+ * measurements. This is a physical property of datacenter infrastructure,
55
+ * not a software configuration that can be patched or spoofed.
56
+ *
57
+ * A VM attempting to inject synthetic ENF ripple into its virtual clock
58
+ * would need to:
59
+ * 1. Know the real-time ENF of the target grid region (requires live API)
60
+ * 2. Modulate the virtual TSC at sub-microsecond precision
61
+ * 3. Match the precise VRM transfer function of the target motherboard
62
+ * This is not a realistic attack surface.
63
+ *
64
+ * Battery devices
65
+ * ───────────────
66
+ * Laptops on battery have no AC ripple. The module detects this via absence
67
+ * of both 100 Hz and 120 Hz signal, combined with very low ripple variance.
68
+ * This is handled by the 'battery_or_conditioned' verdict — treated as
69
+ * inconclusive rather than VM (real laptops exist).
70
+ *
71
+ * Required: crossOriginIsolated = true (COOP + COEP headers)
72
+ * The SAB microsecond timer is required for ENF detection. On browsers where
73
+ * it is unavailable, the module returns { enfAvailable: false }.
74
+ */
75
+
76
+ import { isSabAvailable, collectHighResTimings } from './sabTimer.js';
77
+
78
+ // ── Grid frequency constants ──────────────────────────────────────────────────
79
+ const GRID_60HZ_NOMINAL = 60.0; // Americas, parts of Japan & Korea
80
+ const GRID_50HZ_NOMINAL = 50.0; // EMEA, APAC, most of Asia
81
+ const RIPPLE_60HZ = 120.0; // Full-wave rectified: 2 × 60 Hz
82
+ const RIPPLE_50HZ = 100.0; // Full-wave rectified: 2 × 50 Hz
83
+ const RIPPLE_SLACK_HZ = 2.0; // ±2 Hz around nominal (accounts for VRM response)
84
+ const MIN_RIPPLE_POWER = 0.04; // Minimum power ratio to declare ripple present
85
+ const SNR_THRESHOLD = 2.0; // Signal-to-noise ratio for confident detection
86
+
87
+ // ── Probe parameters ──────────────────────────────────────────────────────────
88
+ // We need enough samples at sufficient rate to resolve 100–120 Hz.
89
+ // Nyquist: sample_rate > 240 Hz (need >2× the highest target frequency).
90
+ // With ~1 ms per iteration, 100 Hz ≈ 10 samples per cycle — adequate.
91
+ // We want at least 20 full cycles → 200 iterations minimum.
92
+ const PROBE_ITERATIONS = 512; // power of 2 for clean FFT
93
+ const PROBE_MATRIX_SIZE = 16; // small matrix → ~1 ms/iter → ~500 Hz sample rate
94
+
95
+ /* ─── collectEnfTimings ─────────────────────────────────────────────────────── */
96
+
97
+ /**
98
+ * @param {object} [opts]
99
+ * @param {number} [opts.iterations=512]
100
+ * @returns {Promise<EnfResult>}
101
+ */
102
+ export async function collectEnfTimings(opts = {}) {
103
+ const { iterations = PROBE_ITERATIONS } = opts;
104
+
105
+ if (!isSabAvailable()) {
106
+ return _noEnf('SharedArrayBuffer not available — COOP+COEP headers required');
107
+ }
108
+
109
+ // Collect high-resolution CPU timing series
110
+ const { timings, resolutionUs } = collectHighResTimings({
111
+ iterations,
112
+ matrixSize: PROBE_MATRIX_SIZE,
113
+ });
114
+
115
+ if (timings.length < 128) {
116
+ return _noEnf('insufficient timing samples');
117
+ }
118
+
119
+ // Estimate the sample rate from actual timing
120
+ const meanIterMs = timings.reduce((s, v) => s + v, 0) / timings.length;
121
+ const sampleRateHz = meanIterMs > 0 ? 1000 / meanIterMs : 0;
122
+
123
+ if (sampleRateHz < 60) {
124
+ return _noEnf(`sample rate too low for ENF detection: ${sampleRateHz.toFixed(0)} Hz`);
125
+ }
126
+
127
+ // ── Power Spectral Density ────────────────────────────────────────────────
128
+ const n = timings.length;
129
+ const psd = _computePsd(timings, sampleRateHz);
130
+
131
+ // Find the dominant frequency peak
132
+ const peakIdx = psd.powers.reduce((best, v, i) => i > 0 && v > psd.powers[best] ? i : best, 1);
133
+ const peakFreq = psd.freqs[peakIdx];
134
+
135
+ // Power in 100 Hz window vs 120 Hz window
136
+ const power100 = _bandPower(psd, RIPPLE_50HZ, RIPPLE_SLACK_HZ);
137
+ const power120 = _bandPower(psd, RIPPLE_60HZ, RIPPLE_SLACK_HZ);
138
+ const baseline = _baselinePower(psd, [
139
+ [RIPPLE_50HZ - RIPPLE_SLACK_HZ, RIPPLE_50HZ + RIPPLE_SLACK_HZ],
140
+ [RIPPLE_60HZ - RIPPLE_SLACK_HZ, RIPPLE_60HZ + RIPPLE_SLACK_HZ],
141
+ ]);
142
+
143
+ const snr100 = baseline > 0 ? power100 / baseline : 0;
144
+ const snr120 = baseline > 0 ? power120 / baseline : 0;
145
+
146
+ // ── Verdict ───────────────────────────────────────────────────────────────
147
+ const has100 = power100 > MIN_RIPPLE_POWER && snr100 > SNR_THRESHOLD;
148
+ const has120 = power120 > MIN_RIPPLE_POWER && snr120 > SNR_THRESHOLD;
149
+
150
+ let gridFrequency = null;
151
+ let gridRegion = 'unknown';
152
+ let ripplePower = 0;
153
+ let nominalHz = null;
154
+
155
+ if (has120 && power120 >= power100) {
156
+ gridFrequency = GRID_60HZ_NOMINAL;
157
+ gridRegion = 'americas';
158
+ ripplePower = power120;
159
+ nominalHz = RIPPLE_60HZ;
160
+ } else if (has100) {
161
+ gridFrequency = GRID_50HZ_NOMINAL;
162
+ gridRegion = 'emea_apac';
163
+ ripplePower = power100;
164
+ nominalHz = RIPPLE_50HZ;
165
+ }
166
+
167
+ const ripplePresent = has100 || has120;
168
+
169
+ // ── ENF deviation (temporal fingerprint) ─────────────────────────────────
170
+ // The precise ripple frequency deviates from nominal by ±0.1 Hz in real time.
171
+ // We measure the peak frequency in the ripple band to extract this deviation.
172
+ let enfDeviation = null;
173
+ if (ripplePresent && nominalHz !== null) {
174
+ const preciseRippleFreq = _precisePeakFreq(psd, nominalHz, RIPPLE_SLACK_HZ);
175
+ enfDeviation = +(preciseRippleFreq - nominalHz).toFixed(3); // Hz deviation from nominal
176
+ }
177
+
178
+ // ── Verdict ───────────────────────────────────────────────────────────────
179
+ const verdict =
180
+ !ripplePresent ? 'no_grid_signal' // VM, UPS, or battery
181
+ : gridRegion === 'americas' ? 'grid_60hz'
182
+ : gridRegion === 'emea_apac' ? 'grid_50hz'
183
+ : 'grid_detected_region_unknown';
184
+
185
+ const isVmIndicator = !ripplePresent && sampleRateHz > 100;
186
+ // High sample rate + no ripple = conditioned power (datacenter)
187
+
188
+ return {
189
+ enfAvailable: true,
190
+ ripplePresent,
191
+ gridFrequency,
192
+ gridRegion,
193
+ ripplePower: +ripplePower.toFixed(4),
194
+ snr50hz: +snr100.toFixed(2),
195
+ snr60hz: +snr120.toFixed(2),
196
+ enfDeviation,
197
+ sampleRateHz: +sampleRateHz.toFixed(1),
198
+ resolutionUs,
199
+ verdict,
200
+ isVmIndicator,
201
+ // For cross-referencing against public ENF databases (forensic timestamp)
202
+ temporalAnchor: enfDeviation !== null ? {
203
+ nominalHz,
204
+ measuredRippleHz: +(nominalHz + enfDeviation).toFixed(4),
205
+ capturedAt: Date.now(),
206
+ // Matches format used by ENF forensic databases:
207
+ // https://www.enf.cc | UK National Grid ESO data
208
+ gridHz: gridFrequency,
209
+ } : null,
210
+ };
211
+ }
212
+
213
+ /* ─── Power Spectral Density (Welch-inspired DFT) ───────────────────────── */
214
+
215
+ function _computePsd(signal, sampleRateHz) {
216
+ const n = signal.length;
217
+ const mean = signal.reduce((s, v) => s + v, 0) / n;
218
+
219
+ // Remove DC offset and apply Hann window
220
+ const windowed = signal.map((v, i) => {
221
+ const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1))); // Hann
222
+ return (v - mean) * w;
223
+ });
224
+
225
+ // DFT up to Nyquist — only need up to ~200 Hz so we cap bins
226
+ const maxFreq = Math.min(200, sampleRateHz / 2);
227
+ const maxBin = Math.floor(maxFreq * n / sampleRateHz);
228
+
229
+ const powers = new Float64Array(maxBin);
230
+ const freqs = new Float64Array(maxBin);
231
+
232
+ for (let k = 1; k < maxBin; k++) {
233
+ let re = 0, im = 0;
234
+ for (let t = 0; t < n; t++) {
235
+ const angle = (2 * Math.PI * k * t) / n;
236
+ re += windowed[t] * Math.cos(angle);
237
+ im -= windowed[t] * Math.sin(angle);
238
+ }
239
+ powers[k] = (re * re + im * im) / (n * n);
240
+ freqs[k] = (k * sampleRateHz) / n;
241
+ }
242
+
243
+ // Normalise powers so they sum to 1 (makes thresholds sample-count-independent)
244
+ const total = powers.reduce((s, v) => s + v, 0);
245
+ if (total > 0) for (let i = 0; i < powers.length; i++) powers[i] /= total;
246
+
247
+ return { powers, freqs };
248
+ }
249
+
250
+ function _bandPower(psd, centerHz, halfwidthHz) {
251
+ let power = 0;
252
+ for (let i = 0; i < psd.freqs.length; i++) {
253
+ if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz) {
254
+ power += psd.powers[i];
255
+ }
256
+ }
257
+ return power;
258
+ }
259
+
260
+ function _baselinePower(psd, excludeBands) {
261
+ let sum = 0, count = 0;
262
+ for (let i = 0; i < psd.freqs.length; i++) {
263
+ const f = psd.freqs[i];
264
+ const excluded = excludeBands.some(([lo, hi]) => f >= lo && f <= hi);
265
+ if (!excluded && f > 10 && f < 200) { sum += psd.powers[i]; count++; }
266
+ }
267
+ return count > 0 ? sum / count : 0;
268
+ }
269
+
270
+ function _precisePeakFreq(psd, centerHz, halfwidthHz) {
271
+ // Quadratic interpolation around the peak bin for sub-bin precision
272
+ let peakBin = 0, peakPow = -Infinity;
273
+ for (let i = 0; i < psd.freqs.length; i++) {
274
+ if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz && psd.powers[i] > peakPow) {
275
+ peakPow = psd.powers[i]; peakBin = i;
276
+ }
277
+ }
278
+ if (peakBin <= 0 || peakBin >= psd.powers.length - 1) return psd.freqs[peakBin];
279
+
280
+ // Quadratic peak interpolation (Jacobsen method)
281
+ const alpha = psd.powers[peakBin - 1];
282
+ const beta = psd.powers[peakBin];
283
+ const gamma = psd.powers[peakBin + 1];
284
+ const denom = alpha - 2 * beta + gamma;
285
+ if (Math.abs(denom) < 1e-14) return psd.freqs[peakBin];
286
+ const deltaBin = 0.5 * (alpha - gamma) / denom;
287
+ const binWidth = psd.freqs[1] - psd.freqs[0];
288
+ return psd.freqs[peakBin] + deltaBin * binWidth;
289
+ }
290
+
291
+ function _noEnf(reason) {
292
+ return {
293
+ enfAvailable: false, ripplePresent: false, gridFrequency: null,
294
+ gridRegion: 'unknown', ripplePower: 0, snr50hz: 0, snr60hz: 0,
295
+ enfDeviation: null, sampleRateHz: 0, resolutionUs: 0,
296
+ verdict: 'unavailable', isVmIndicator: false, temporalAnchor: null, reason,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * @typedef {object} EnfResult
302
+ * @property {boolean} enfAvailable
303
+ * @property {boolean} ripplePresent false = VM / datacenter / battery
304
+ * @property {number|null} gridFrequency 50 or 60 Hz
305
+ * @property {string} gridRegion 'americas' | 'emea_apac' | 'unknown'
306
+ * @property {number} ripplePower normalised PSD power at grid harmonic
307
+ * @property {number|null} enfDeviation Hz deviation from nominal (temporal fingerprint)
308
+ * @property {string} verdict
309
+ * @property {boolean} isVmIndicator true if signal absence + high sample rate
310
+ * @property {object|null} temporalAnchor forensic timestamp anchor
311
+ */