@svrnsec/pulse 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/svrnsec-pulse.js +7 -0
- package/dist/pulse.cjs.js +2137 -205
- package/dist/pulse.cjs.js.map +1 -1
- package/dist/pulse.esm.js +2129 -206
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +258 -0
- package/package.json +51 -24
- package/src/analysis/audio.js +213 -0
- package/src/analysis/coherence.js +502 -0
- package/src/analysis/heuristic.js +428 -0
- package/src/analysis/jitter.js +446 -0
- package/src/analysis/llm.js +472 -0
- package/src/analysis/provider.js +248 -0
- package/src/analysis/trustScore.js +331 -0
- package/src/cli/args.js +36 -0
- package/src/cli/commands/scan.js +192 -0
- package/src/cli/runner.js +157 -0
- package/src/collector/adaptive.js +200 -0
- package/src/collector/bio.js +287 -0
- package/src/collector/canvas.js +239 -0
- package/src/collector/dram.js +203 -0
- package/src/collector/enf.js +311 -0
- package/src/collector/entropy.js +195 -0
- package/src/collector/gpu.js +245 -0
- package/src/collector/sabTimer.js +191 -0
- package/src/fingerprint.js +475 -0
- package/src/index.js +342 -0
- package/src/integrations/react-native.js +459 -0
- package/src/proof/challenge.js +249 -0
- package/src/proof/fingerprint.js +61 -5
- package/src/terminal.js +263 -0
- package/src/update-notifier.js +264 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/pulse — DRAM Refresh Cycle Detector
|
|
3
|
+
*
|
|
4
|
+
* DDR4 DRAM refreshes every 7.8 ms (tREFI per JEDEC JESD79-4). During a
|
|
5
|
+
* refresh, the memory controller stalls all access requests for ~350 ns.
|
|
6
|
+
* In a tight sequential memory access loop this appears as a periodic
|
|
7
|
+
* slowdown — detectable as a ~128Hz peak in the autocorrelation of access
|
|
8
|
+
* timings.
|
|
9
|
+
*
|
|
10
|
+
* Virtual machines do not have physical DRAM. The hypervisor's memory
|
|
11
|
+
* subsystem does not reproduce the refresh cycle because:
|
|
12
|
+
* 1. The guest never touches real DRAM directly — there is always a
|
|
13
|
+
* hypervisor-controlled indirection layer.
|
|
14
|
+
* 2. EPT/NPT (Extended/Nested Page Tables) absorb the timing.
|
|
15
|
+
* 3. The hypervisor's memory balloon driver further smooths access latency.
|
|
16
|
+
*
|
|
17
|
+
* What we detect
|
|
18
|
+
* ──────────────
|
|
19
|
+
* refreshPeriodMs estimated DRAM refresh period (should be ~7.8ms on real DDR4)
|
|
20
|
+
* refreshPresent true if the ~7.8ms periodicity is statistically significant
|
|
21
|
+
* peakLag autocorrelation lag with the highest power (units: sample index)
|
|
22
|
+
* peakPower autocorrelation power at peakLag (0–1)
|
|
23
|
+
* verdict 'dram' | 'virtual' | 'ambiguous'
|
|
24
|
+
*
|
|
25
|
+
* Calibration
|
|
26
|
+
* ───────────
|
|
27
|
+
* We allocate a buffer large enough to exceed all CPU caches (typically
|
|
28
|
+
* L3 = 8–32 MB on consumer parts). Sequential reads then go to DRAM, not
|
|
29
|
+
* cache. The refresh stall is only visible when we're actually hitting DRAM —
|
|
30
|
+
* a cache-resident access loop shows no refresh signal.
|
|
31
|
+
*
|
|
32
|
+
* Buffer size: 64 MB — comfortably above L3 on all tested platforms.
|
|
33
|
+
* Sampling interval: ~1 ms per iteration (chosen to resolve 7.8ms at ≥8 pts).
|
|
34
|
+
* Total probe time: ~400 ms — well within the fingerprint collection window.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const DRAM_REFRESH_MS = 7.8; // JEDEC DDR4 nominal
|
|
38
|
+
const DRAM_REFRESH_SLACK = 1.5; // ±1.5 ms acceptable range for real hardware
|
|
39
|
+
const BUFFER_MB = 64; // must exceed L3 cache
|
|
40
|
+
const PROBE_ITERATIONS = 400; // ~400 ms total
|
|
41
|
+
|
|
42
|
+
/* ─── collectDramTimings ─────────────────────────────────────────────────── */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {number} [opts.iterations=400]
|
|
47
|
+
* @param {number} [opts.bufferMb=64]
|
|
48
|
+
* @returns {{ timings: number[], refreshPeriodMs: number|null,
|
|
49
|
+
* refreshPresent: boolean, peakLag: number, peakPower: number,
|
|
50
|
+
* verdict: string }}
|
|
51
|
+
*/
|
|
52
|
+
export function collectDramTimings(opts = {}) {
|
|
53
|
+
const {
|
|
54
|
+
iterations = PROBE_ITERATIONS,
|
|
55
|
+
bufferMb = BUFFER_MB,
|
|
56
|
+
} = opts;
|
|
57
|
+
|
|
58
|
+
// ── Allocate cache-busting buffer ────────────────────────────────────────
|
|
59
|
+
const nElements = (bufferMb * 1024 * 1024) / 8; // 64-bit doubles
|
|
60
|
+
let buf;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
buf = new Float64Array(nElements);
|
|
64
|
+
// Touch every cache line to ensure OS actually maps the pages
|
|
65
|
+
const stride = 64 / 8; // 64-byte cache lines, 8 bytes per element
|
|
66
|
+
for (let i = 0; i < nElements; i += stride) buf[i] = i;
|
|
67
|
+
} catch {
|
|
68
|
+
// Allocation failure (memory constrained) — cannot run this probe
|
|
69
|
+
return _noSignal('buffer allocation failed');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Sequential access loop ───────────────────────────────────────────────
|
|
73
|
+
// Each iteration does a full sequential pass over `passElements` worth of
|
|
74
|
+
// the buffer. Pass size is tuned so each iteration takes ~1 ms wall-clock,
|
|
75
|
+
// giving us enough resolution to see the 7.8 ms refresh cycle.
|
|
76
|
+
//
|
|
77
|
+
// We start with a small pass and auto-calibrate to hit the 1 ms target.
|
|
78
|
+
const passElements = _calibratePassSize(buf);
|
|
79
|
+
|
|
80
|
+
const timings = new Float64Array(iterations);
|
|
81
|
+
let checksum = 0;
|
|
82
|
+
|
|
83
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
84
|
+
const t0 = performance.now();
|
|
85
|
+
for (let i = 0; i < passElements; i++) checksum += buf[i];
|
|
86
|
+
timings[iter] = performance.now() - t0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Prevent dead-code elimination
|
|
90
|
+
if (checksum === 0) buf[0] = 1;
|
|
91
|
+
|
|
92
|
+
// ── Autocorrelation over timings ─────────────────────────────────────────
|
|
93
|
+
// The refresh stall appears as elevated autocorrelation at lag ≈ 7.8 / Δt
|
|
94
|
+
// where Δt is the mean iteration time in ms.
|
|
95
|
+
const meanIterMs = _mean(timings);
|
|
96
|
+
if (meanIterMs <= 0) return _noSignal('zero mean iteration time');
|
|
97
|
+
|
|
98
|
+
const targetLag = Math.round(DRAM_REFRESH_MS / meanIterMs);
|
|
99
|
+
const maxLag = Math.min(Math.round(50 / meanIterMs), iterations >> 1);
|
|
100
|
+
|
|
101
|
+
const ac = _autocorr(Array.from(timings), maxLag);
|
|
102
|
+
|
|
103
|
+
// Find the peak in the range [targetLag ± slack]
|
|
104
|
+
const slackLags = Math.round(DRAM_REFRESH_SLACK / meanIterMs);
|
|
105
|
+
const lagLo = Math.max(1, targetLag - slackLags);
|
|
106
|
+
const lagHi = Math.min(maxLag, targetLag + slackLags);
|
|
107
|
+
|
|
108
|
+
let peakPower = -Infinity;
|
|
109
|
+
let peakLag = targetLag;
|
|
110
|
+
for (let l = lagLo; l <= lagHi; l++) {
|
|
111
|
+
if (ac[l - 1] > peakPower) {
|
|
112
|
+
peakPower = ac[l - 1];
|
|
113
|
+
peakLag = l;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Baseline: average autocorrelation outside the refresh window
|
|
118
|
+
const baseline = _mean(
|
|
119
|
+
Array.from({ length: maxLag }, (_, i) => ac[i])
|
|
120
|
+
.filter((_, i) => i + 1 < lagLo || i + 1 > lagHi)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const snr = baseline > 0 ? peakPower / baseline : 0;
|
|
124
|
+
const refreshPresent = peakPower > 0.15 && snr > 1.8;
|
|
125
|
+
const refreshPeriodMs = refreshPresent ? peakLag * meanIterMs : null;
|
|
126
|
+
|
|
127
|
+
const verdict =
|
|
128
|
+
refreshPresent && refreshPeriodMs !== null &&
|
|
129
|
+
Math.abs(refreshPeriodMs - DRAM_REFRESH_MS) < DRAM_REFRESH_SLACK
|
|
130
|
+
? 'dram'
|
|
131
|
+
: peakPower < 0.05
|
|
132
|
+
? 'virtual'
|
|
133
|
+
: 'ambiguous';
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
timings: Array.from(timings),
|
|
137
|
+
refreshPeriodMs,
|
|
138
|
+
refreshPresent,
|
|
139
|
+
peakLag,
|
|
140
|
+
peakPower: +peakPower.toFixed(4),
|
|
141
|
+
snr: +snr.toFixed(2),
|
|
142
|
+
meanIterMs: +meanIterMs.toFixed(3),
|
|
143
|
+
verdict,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ─── helpers ────────────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
function _noSignal(reason) {
|
|
150
|
+
return {
|
|
151
|
+
timings: [], refreshPeriodMs: null, refreshPresent: false,
|
|
152
|
+
peakLag: 0, peakPower: 0, snr: 0, meanIterMs: 0,
|
|
153
|
+
verdict: 'ambiguous', reason,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run a quick calibration pass to find how many elements to read per
|
|
159
|
+
* iteration so each iteration takes approximately 1 ms.
|
|
160
|
+
*/
|
|
161
|
+
function _calibratePassSize(buf) {
|
|
162
|
+
const target = 1.0; // ms
|
|
163
|
+
let n = Math.min(100_000, buf.length);
|
|
164
|
+
let elapsed = 0;
|
|
165
|
+
let dummy = 0;
|
|
166
|
+
|
|
167
|
+
// Warm up
|
|
168
|
+
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
169
|
+
|
|
170
|
+
// Measure
|
|
171
|
+
const t0 = performance.now();
|
|
172
|
+
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
173
|
+
elapsed = performance.now() - t0;
|
|
174
|
+
if (dummy === 0) buf[0] = 1; // prevent DCE
|
|
175
|
+
|
|
176
|
+
if (elapsed <= 0) return n;
|
|
177
|
+
return Math.min(buf.length, Math.round(n * (target / elapsed)));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _mean(arr) {
|
|
181
|
+
if (!arr.length) return 0;
|
|
182
|
+
return arr.reduce((s, v) => s + v, 0) / arr.length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _autocorr(data, maxLag) {
|
|
186
|
+
const n = data.length;
|
|
187
|
+
const mean = _mean(data);
|
|
188
|
+
let v = 0;
|
|
189
|
+
for (let i = 0; i < n; i++) v += (data[i] - mean) ** 2;
|
|
190
|
+
v /= n;
|
|
191
|
+
|
|
192
|
+
const result = new Float64Array(maxLag);
|
|
193
|
+
if (v < 1e-14) return result;
|
|
194
|
+
|
|
195
|
+
for (let lag = 1; lag <= maxLag; lag++) {
|
|
196
|
+
let cov = 0;
|
|
197
|
+
for (let i = 0; i < n - lag; i++) {
|
|
198
|
+
cov += (data[i] - mean) * (data[i + lag] - mean);
|
|
199
|
+
}
|
|
200
|
+
result[lag - 1] = cov / ((n - lag) * v);
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sovereign/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
|
+
*/
|