@svrnsec/pulse 0.6.0 → 0.8.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/LICENSE +21 -21
- package/README.md +883 -622
- package/SECURITY.md +86 -86
- package/bin/svrnsec-pulse.js +7 -7
- package/dist/{pulse.cjs.js → pulse.cjs} +6379 -6420
- package/dist/pulse.cjs.map +1 -0
- package/dist/pulse.esm.js +6380 -6421
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +895 -846
- package/package.json +185 -165
- package/pkg/pulse_core.js +174 -173
- package/src/analysis/audio.js +213 -213
- package/src/analysis/authenticityAudit.js +408 -390
- package/src/analysis/coherence.js +502 -502
- package/src/analysis/coordinatedBehavior.js +825 -0
- package/src/analysis/heuristic.js +428 -428
- package/src/analysis/jitter.js +446 -446
- package/src/analysis/llm.js +473 -472
- package/src/analysis/populationEntropy.js +404 -403
- package/src/analysis/provider.js +248 -248
- package/src/analysis/refraction.js +392 -0
- package/src/analysis/trustScore.js +356 -356
- package/src/cli/args.js +36 -36
- package/src/cli/commands/scan.js +192 -192
- package/src/cli/runner.js +157 -157
- package/src/collector/adaptive.js +200 -200
- package/src/collector/bio.js +297 -287
- package/src/collector/canvas.js +247 -239
- package/src/collector/dram.js +203 -203
- package/src/collector/enf.js +311 -311
- package/src/collector/entropy.js +195 -195
- package/src/collector/gpu.js +248 -245
- package/src/collector/idleAttestation.js +480 -480
- package/src/collector/sabTimer.js +189 -191
- package/src/fingerprint.js +475 -475
- package/src/index.js +342 -342
- package/src/integrations/react-native.js +462 -459
- package/src/integrations/react.js +184 -185
- package/src/middleware/express.js +155 -155
- package/src/middleware/next.js +174 -175
- package/src/proof/challenge.js +249 -249
- package/src/proof/engagementToken.js +426 -394
- package/src/proof/fingerprint.js +268 -268
- package/src/proof/validator.js +83 -143
- package/src/registry/serializer.js +349 -349
- package/src/terminal.js +263 -263
- package/src/update-notifier.js +259 -264
- package/dist/pulse.cjs.js.map +0 -1
|
@@ -1,480 +1,480 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @svrnsec/pulse — Idle Attestation Collector
|
|
3
|
-
*
|
|
4
|
-
* Click farms run thousands of real devices at sustained maximum throughput —
|
|
5
|
-
* they physically cannot let a device idle. This module builds a cryptographic
|
|
6
|
-
* proof that a device experienced a genuine rest period between interactions:
|
|
7
|
-
* thermal cooling, CPU clock-scaling, and a hash-chained measurement sequence
|
|
8
|
-
* that cannot be back-filled faster than real time.
|
|
9
|
-
*
|
|
10
|
-
* Physics basis
|
|
11
|
-
* ─────────────
|
|
12
|
-
* Real device between interactions:
|
|
13
|
-
* → CPU frequency drops via DVFS (Dynamic Voltage/Frequency Scaling)
|
|
14
|
-
* → DRAM access latency rises as the front-side bus slows down
|
|
15
|
-
* → Thermal mass of die + PCB means temperature decays exponentially
|
|
16
|
-
* → Timing variance follows Newton's Law of Cooling — a smooth curve
|
|
17
|
-
*
|
|
18
|
-
* Click farm device with paused script:
|
|
19
|
-
* → CPU load drops from ~100% to ~0% INSTANTLY (OS task queue emptied)
|
|
20
|
-
* → DRAM timing shows a STEP FUNCTION, not an exponential curve
|
|
21
|
-
* → The step is economically forced: farm scripts resume within 90s
|
|
22
|
-
* to maintain throughput; real thermal settling takes minutes
|
|
23
|
-
*
|
|
24
|
-
* Hash chain
|
|
25
|
-
* ──────────
|
|
26
|
-
* Each idle sample produces a chain node:
|
|
27
|
-
* node[n].hash = SHA-256(node[n-1].hash ‖ ts ‖ meanMs ‖ variance)
|
|
28
|
-
*
|
|
29
|
-
* The chain proves samples were taken in sequence at regular intervals.
|
|
30
|
-
* N nodes at 30-second intervals = (N−1)×30s minimum elapsed time.
|
|
31
|
-
* It cannot be fabricated faster than real time without the server
|
|
32
|
-
* noticing the timing impossibility.
|
|
33
|
-
*
|
|
34
|
-
* Thermal transition taxonomy
|
|
35
|
-
* ───────────────────────────
|
|
36
|
-
* hot_to_cold → smooth exponential variance decay (genuine cooling ✓)
|
|
37
|
-
* cold → device was already at rest temperature (genuine idle ✓)
|
|
38
|
-
* cooling → mild, ongoing decay (genuine idle ✓)
|
|
39
|
-
* warming → device heating up (uncommon during idle)
|
|
40
|
-
* sustained_hot → elevated variance throughout (click farm: constant load ✗)
|
|
41
|
-
* step_function → abrupt single-interval drop (click farm: script paused ✗)
|
|
42
|
-
* unknown → insufficient samples to classify
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
import { sha256 } from '@noble/hashes/sha256';
|
|
46
|
-
import { bytesToHex,
|
|
47
|
-
utf8ToBytes,
|
|
48
|
-
randomBytes } from '@noble/hashes/utils';
|
|
49
|
-
|
|
50
|
-
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
/** Minimum idle duration before issuing a proof.
|
|
53
|
-
* Farm scripts pause for < 30s to maintain throughput.
|
|
54
|
-
* This threshold creates a real economic cost: 45s idle × 1000 devices =
|
|
55
|
-
* 12.5 device-hours of forced downtime per 1000 tokens. */
|
|
56
|
-
const MIN_IDLE_MS = 45_000;
|
|
57
|
-
|
|
58
|
-
/** Sampling interval. 30s gives 3 nodes in a 90s session — enough to
|
|
59
|
-
* differentiate a cooling curve from a step function. */
|
|
60
|
-
const SAMPLE_INTERVAL_MS = 30_000;
|
|
61
|
-
|
|
62
|
-
/** Grace period after focus/visibility loss before declaring idle.
|
|
63
|
-
* Absorbs rapid tab switches and accidental blur events. */
|
|
64
|
-
const IDLE_WATCH_GRACE_MS = 5_000;
|
|
65
|
-
|
|
66
|
-
/** Mini probe buffer — 16 MB exceeds L3 cache on most consumer devices,
|
|
67
|
-
* forcing reads to DRAM. Small enough that the probe finishes in < 100ms,
|
|
68
|
-
* so we don't meaningfully disturb the idle state we're measuring. */
|
|
69
|
-
const MINI_BUFFER_MB = 16;
|
|
70
|
-
|
|
71
|
-
/** Mini probe iteration count. ~80ms total wall-clock time. */
|
|
72
|
-
const MINI_ITERATIONS = 80;
|
|
73
|
-
|
|
74
|
-
/** Variance at or below this value indicates a device at rest temperature.
|
|
75
|
-
* Calibrated from empirical measurements on idle consumer hardware. */
|
|
76
|
-
const COLD_VARIANCE_THRESHOLD = 0.003;
|
|
77
|
-
|
|
78
|
-
/** Variance above this value indicates sustained CPU load — characteristic
|
|
79
|
-
* of click farm operation (continuous task execution). */
|
|
80
|
-
const HOT_VARIANCE_THRESHOLD = 0.025;
|
|
81
|
-
|
|
82
|
-
/** If more than this fraction of the total variance drop happens in the
|
|
83
|
-
* first sample interval, we classify the transition as 'step_function'. */
|
|
84
|
-
const STEP_FUNCTION_RATIO = 0.75;
|
|
85
|
-
|
|
86
|
-
// ── State machine ─────────────────────────────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
/** @enum {string} */
|
|
89
|
-
const State = Object.freeze({
|
|
90
|
-
ACTIVE: 'active', // device in normal use
|
|
91
|
-
IDLE_WATCH: 'idle_watch', // focus lost, in grace period
|
|
92
|
-
IDLE_SAMPLING: 'idle_sampling', // sampling in progress, chain building
|
|
93
|
-
IDLE_COMMITTED: 'idle_committed', // proof ready to consume
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// ── createIdleMonitor ─────────────────────────────────────────────────────────
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Create a stateful idle monitor for the current session.
|
|
100
|
-
*
|
|
101
|
-
* **Browser**: automatically hooks `visibilitychange` and `blur`/`focus`.
|
|
102
|
-
* Call `monitor.start()` once on page load and `monitor.stop()` on unload.
|
|
103
|
-
*
|
|
104
|
-
* **Node.js / React Native**: call `monitor.declareIdle()` and
|
|
105
|
-
* `monitor.declareActive()` manually to drive the state machine.
|
|
106
|
-
*
|
|
107
|
-
* @param {object} [opts]
|
|
108
|
-
* @param {number} [opts.minIdleMs=45000] minimum idle ms for valid proof
|
|
109
|
-
* @param {number} [opts.sampleIntervalMs=30000] thermal sampling interval
|
|
110
|
-
* @param {string} [opts.sessionNonce] ties hash chain to this session
|
|
111
|
-
* @returns {IdleMonitor}
|
|
112
|
-
*/
|
|
113
|
-
export function createIdleMonitor(opts = {}) {
|
|
114
|
-
const {
|
|
115
|
-
minIdleMs = MIN_IDLE_MS,
|
|
116
|
-
sampleIntervalMs = SAMPLE_INTERVAL_MS,
|
|
117
|
-
sessionNonce = bytesToHex(randomBytes(8)),
|
|
118
|
-
} = opts;
|
|
119
|
-
|
|
120
|
-
// ── Mutable private state (encapsulated in closure — no global mutation) ───
|
|
121
|
-
let _state = State.ACTIVE;
|
|
122
|
-
let _idleStartMs = 0;
|
|
123
|
-
let _watchTimer = null;
|
|
124
|
-
let _sampleTimer = null;
|
|
125
|
-
let _chain = _genesisHash(sessionNonce);
|
|
126
|
-
let _samples = /** @type {ThermalSample[]} */ ([]);
|
|
127
|
-
let _pendingProof = null;
|
|
128
|
-
let _probeBuffer = null; // allocated lazily on first sample, then reused
|
|
129
|
-
|
|
130
|
-
// ── State transition: ACTIVE / IDLE_COMMITTED → IDLE_WATCH ───────────────
|
|
131
|
-
function _enterWatch() {
|
|
132
|
-
if (_state !== State.ACTIVE && _state !== State.IDLE_COMMITTED) return;
|
|
133
|
-
// Discard any unconsumed proof — a new idle cycle supersedes the old one.
|
|
134
|
-
_pendingProof = null;
|
|
135
|
-
_state = State.IDLE_WATCH;
|
|
136
|
-
_watchTimer = setTimeout(_enterSampling, IDLE_WATCH_GRACE_MS);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ── State transition: IDLE_WATCH → IDLE_SAMPLING ──────────────────────────
|
|
140
|
-
function _enterSampling() {
|
|
141
|
-
_state = State.IDLE_SAMPLING;
|
|
142
|
-
_idleStartMs = Date.now();
|
|
143
|
-
_samples = [];
|
|
144
|
-
_chain = _genesisHash(`${sessionNonce}:${_idleStartMs}`);
|
|
145
|
-
|
|
146
|
-
// Take first sample immediately, then on interval
|
|
147
|
-
_tick();
|
|
148
|
-
_sampleTimer = setInterval(_tick, sampleIntervalMs);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ── Periodic sample tick ───────────────────────────────────────────────────
|
|
152
|
-
function _tick() {
|
|
153
|
-
// Allocate probe buffer once; reuse to avoid GC pressure every 30s
|
|
154
|
-
if (!_probeBuffer) _probeBuffer = _allocBuffer();
|
|
155
|
-
const sample = _miniProbe(_probeBuffer);
|
|
156
|
-
_samples.push(sample);
|
|
157
|
-
_chain = _chainStep(_chain, sample);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ── State transition: IDLE_SAMPLING → IDLE_COMMITTED or ACTIVE ────────────
|
|
161
|
-
function _commitOrReset() {
|
|
162
|
-
clearTimeout(_watchTimer);
|
|
163
|
-
clearInterval(_sampleTimer);
|
|
164
|
-
_watchTimer = null;
|
|
165
|
-
_sampleTimer = null;
|
|
166
|
-
|
|
167
|
-
const idleDurationMs = Date.now() - _idleStartMs;
|
|
168
|
-
const hasEnoughTime = idleDurationMs >= minIdleMs;
|
|
169
|
-
const hasEnoughSamples = _samples.length >= 2;
|
|
170
|
-
|
|
171
|
-
if (_state === State.IDLE_SAMPLING && hasEnoughTime && hasEnoughSamples) {
|
|
172
|
-
_pendingProof = _buildProof(_chain, _samples, idleDurationMs);
|
|
173
|
-
_state = State.IDLE_COMMITTED;
|
|
174
|
-
} else {
|
|
175
|
-
_reset();
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── Reset to ACTIVE ────────────────────────────────────────────────────────
|
|
180
|
-
function _reset() {
|
|
181
|
-
_state = State.ACTIVE;
|
|
182
|
-
_idleStartMs = 0;
|
|
183
|
-
_samples = [];
|
|
184
|
-
_pendingProof = null;
|
|
185
|
-
_chain = _genesisHash(sessionNonce);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ── Browser event handlers ─────────────────────────────────────────────────
|
|
189
|
-
const _onHide = () => _enterWatch();
|
|
190
|
-
const _onShow = () => { if (_state !== State.ACTIVE) _commitOrReset(); };
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
if (typeof window !== 'undefined') {
|
|
201
|
-
window.addEventListener('blur', _onHide);
|
|
202
|
-
window.addEventListener('focus', _onShow);
|
|
203
|
-
}
|
|
204
|
-
return api;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Deregister browser event listeners and cancel pending timers. */
|
|
208
|
-
function stop() {
|
|
209
|
-
clearTimeout(_watchTimer);
|
|
210
|
-
clearInterval(_sampleTimer);
|
|
211
|
-
if (typeof document !== 'undefined') {
|
|
212
|
-
document.removeEventListener('visibilitychange',
|
|
213
|
-
}
|
|
214
|
-
if (typeof window !== 'undefined') {
|
|
215
|
-
window.removeEventListener('blur', _onHide);
|
|
216
|
-
window.removeEventListener('focus', _onShow);
|
|
217
|
-
}
|
|
218
|
-
return api;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Manual idle declaration for Node.js or non-browser environments. */
|
|
222
|
-
function declareIdle() { _enterWatch(); return api; }
|
|
223
|
-
|
|
224
|
-
/** Manual active declaration for Node.js or non-browser environments. */
|
|
225
|
-
function declareActive() { _commitOrReset(); return api; }
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Consume the pending idle proof — one-time read that resets the monitor.
|
|
229
|
-
* Returns null if no valid proof is ready (device hasn't been idle long enough).
|
|
230
|
-
*
|
|
231
|
-
* @returns {IdleProof|null}
|
|
232
|
-
*/
|
|
233
|
-
function getProof() {
|
|
234
|
-
if (_state !== State.IDLE_COMMITTED || !_pendingProof) return null;
|
|
235
|
-
const proof = { ..._pendingProof, capturedAt: Date.now() };
|
|
236
|
-
_reset();
|
|
237
|
-
return proof;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Current state machine state — useful for debugging and tests. */
|
|
241
|
-
function getState() { return _state; }
|
|
242
|
-
|
|
243
|
-
const api = { start, stop, getProof, getState, declareIdle, declareActive };
|
|
244
|
-
return api;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// ── analyseIdleProof ──────────────────────────────────────────────────────────
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Validate the physical plausibility of an IdleProof before embedding it in
|
|
251
|
-
* an engagement token. Returns advisory warnings without rejecting outright —
|
|
252
|
-
* the server-side verifier makes the final call.
|
|
253
|
-
*
|
|
254
|
-
* @param {IdleProof} proof
|
|
255
|
-
* @returns {{ plausible: boolean, reason?: string, warnings: string[] }}
|
|
256
|
-
*/
|
|
257
|
-
export function analyseIdleProof(proof) {
|
|
258
|
-
if (!proof) return { plausible: false, reason: 'no_proof', warnings: [] };
|
|
259
|
-
|
|
260
|
-
const warnings = [];
|
|
261
|
-
|
|
262
|
-
if (proof.idleDurationMs < MIN_IDLE_MS) {
|
|
263
|
-
return { plausible: false, reason: 'idle_too_short', warnings };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (proof.samples < 2) {
|
|
267
|
-
return { plausible: false, reason: 'insufficient_chain_samples', warnings };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (!proof.chain || proof.chain.length !== 64) {
|
|
271
|
-
return { plausible: false, reason: 'malformed_chain_hash', warnings };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (proof.thermalTransition === 'step_function') {
|
|
275
|
-
warnings.push('abrupt_cpu_transition_detected');
|
|
276
|
-
}
|
|
277
|
-
if (proof.thermalTransition === 'sustained_hot') {
|
|
278
|
-
warnings.push('no_thermal_decay_observed');
|
|
279
|
-
}
|
|
280
|
-
if (proof.coolingMonotonicity < 0.3 && proof.samples >= 3) {
|
|
281
|
-
warnings.push('non_monotonic_cooling_curve');
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return { plausible: true, warnings };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ── Internal: mini DRAM probe ─────────────────────────────────────────────────
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Lightweight DRAM probe: 16 MB buffer, 80 iterations, < 100ms.
|
|
291
|
-
* Returns mean iteration time (reflects CPU clock frequency) and
|
|
292
|
-
* variance (reflects thermal noise intensity).
|
|
293
|
-
*
|
|
294
|
-
* Exported for unit testing — not part of the public API surface.
|
|
295
|
-
*
|
|
296
|
-
* @param {Float64Array} buf pre-allocated cache-busting buffer
|
|
297
|
-
* @returns {ThermalSample}
|
|
298
|
-
*/
|
|
299
|
-
export function _miniProbe(buf) {
|
|
300
|
-
const pass = _calibratePass(buf);
|
|
301
|
-
const timings = new Float64Array(MINI_ITERATIONS);
|
|
302
|
-
let dummy = 0;
|
|
303
|
-
|
|
304
|
-
for (let i = 0; i < MINI_ITERATIONS; i++) {
|
|
305
|
-
const t0 = performance.now();
|
|
306
|
-
for (let j = 0; j < pass; j++) dummy += buf[j];
|
|
307
|
-
timings[i] = performance.now() - t0;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Prevent dead-code elimination of the memory reads
|
|
311
|
-
if (dummy === 0) buf[0] = 1;
|
|
312
|
-
|
|
313
|
-
const mean = _mean(timings, MINI_ITERATIONS);
|
|
314
|
-
const variance = _variance(timings, MINI_ITERATIONS, mean);
|
|
315
|
-
|
|
316
|
-
return {
|
|
317
|
-
ts: Date.now(),
|
|
318
|
-
meanMs: +mean.toFixed(4),
|
|
319
|
-
variance: +variance.toFixed(6),
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function _allocBuffer() {
|
|
324
|
-
const elements = (MINI_BUFFER_MB * 1024 * 1024) / 8;
|
|
325
|
-
try {
|
|
326
|
-
const buf = new Float64Array(elements);
|
|
327
|
-
const stride = 64 / 8; // one element per 64-byte cache line
|
|
328
|
-
for (let i = 0; i < elements; i += stride) buf[i] = i;
|
|
329
|
-
return buf;
|
|
330
|
-
} catch {
|
|
331
|
-
// Memory-constrained fallback — smaller buffer means weaker signal
|
|
332
|
-
return new Float64Array(8_192);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function _calibratePass(buf) {
|
|
337
|
-
// Dynamically size the pass so each iteration takes ~1ms wall-clock.
|
|
338
|
-
// This self-calibrates across device classes (desktop, mobile, low-end).
|
|
339
|
-
const target = 1.0; // ms
|
|
340
|
-
let n = Math.min(50_000, buf.length);
|
|
341
|
-
let dummy = 0;
|
|
342
|
-
|
|
343
|
-
// Warm-up (ensures first measurement isn't cold-start biased)
|
|
344
|
-
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
345
|
-
|
|
346
|
-
const t0 = performance.now();
|
|
347
|
-
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
348
|
-
const elapsed = performance.now() - t0;
|
|
349
|
-
if (dummy === 0) buf[0] = 1;
|
|
350
|
-
|
|
351
|
-
return elapsed > 0
|
|
352
|
-
? Math.min(buf.length, Math.round(n * target / elapsed))
|
|
353
|
-
: n;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// ── Internal: hash chain ──────────────────────────────────────────────────────
|
|
357
|
-
|
|
358
|
-
function _genesisHash(seed) {
|
|
359
|
-
return bytesToHex(sha256(utf8ToBytes(`pulse:idle:genesis:${seed}`)));
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function _chainStep(prevHex, sample) {
|
|
363
|
-
// Each node commits to: previous state, exact timestamp, CPU freq proxy, thermal noise
|
|
364
|
-
const input = `${prevHex}:${sample.ts}:${sample.meanMs}:${sample.variance}`;
|
|
365
|
-
return bytesToHex(sha256(utf8ToBytes(input)));
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// ── Internal: thermal classification ─────────────────────────────────────────
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Classify the thermal transition from an ordered sequence of samples.
|
|
372
|
-
*
|
|
373
|
-
* The key discriminator is whether the variance follows a smooth exponential
|
|
374
|
-
* decay (genuine cooling) or drops abruptly in one interval (farm script pause).
|
|
375
|
-
*
|
|
376
|
-
* @param {ThermalSample[]} samples
|
|
377
|
-
* @returns {{ transition: string, coolingMonotonicity: number }}
|
|
378
|
-
*/
|
|
379
|
-
function _classifyThermal(samples) {
|
|
380
|
-
if (samples.length < 2) {
|
|
381
|
-
return { transition: 'unknown', coolingMonotonicity: 0 };
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const variances = samples.map(s => s.variance);
|
|
385
|
-
const first = variances[0];
|
|
386
|
-
const last = variances[variances.length - 1];
|
|
387
|
-
|
|
388
|
-
// Cooling monotonicity: fraction of consecutive pairs where variance decreased
|
|
389
|
-
let decreasingPairs = 0;
|
|
390
|
-
for (let i = 1; i < variances.length; i++) {
|
|
391
|
-
if (variances[i] < variances[i - 1]) decreasingPairs++;
|
|
392
|
-
}
|
|
393
|
-
const coolingMonotonicity = +(decreasingPairs / (variances.length - 1)).toFixed(3);
|
|
394
|
-
|
|
395
|
-
// Step function detection: > STEP_FUNCTION_RATIO of total drop in first interval
|
|
396
|
-
if (variances.length >= 3) {
|
|
397
|
-
const firstDrop = Math.max(0, first - variances[1]);
|
|
398
|
-
const totalDrop = Math.max(0, first - last);
|
|
399
|
-
const isSignificantDrop = totalDrop > first * 0.15; // must be >15% absolute drop
|
|
400
|
-
const stepRatio = totalDrop > 1e-9 ? firstDrop / totalDrop : 0;
|
|
401
|
-
|
|
402
|
-
if (isSignificantDrop && stepRatio > STEP_FUNCTION_RATIO) {
|
|
403
|
-
return { transition: 'step_function', coolingMonotonicity };
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Classify by absolute variance levels and direction
|
|
408
|
-
if (first < COLD_VARIANCE_THRESHOLD) {
|
|
409
|
-
return { transition: 'cold', coolingMonotonicity };
|
|
410
|
-
}
|
|
411
|
-
if (last > first * 1.10) {
|
|
412
|
-
return { transition: 'warming', coolingMonotonicity };
|
|
413
|
-
}
|
|
414
|
-
if (first > HOT_VARIANCE_THRESHOLD && last > HOT_VARIANCE_THRESHOLD * 0.85) {
|
|
415
|
-
return { transition: 'sustained_hot', coolingMonotonicity };
|
|
416
|
-
}
|
|
417
|
-
if ((first - last) / (first + 1e-9) > 0.12 && coolingMonotonicity >= 0.5) {
|
|
418
|
-
return { transition: 'hot_to_cold', coolingMonotonicity };
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
return { transition: 'cooling', coolingMonotonicity };
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function _buildProof(chain, samples, idleDurationMs) {
|
|
425
|
-
const { transition, coolingMonotonicity } = _classifyThermal(samples);
|
|
426
|
-
return {
|
|
427
|
-
chain,
|
|
428
|
-
samples: samples.length,
|
|
429
|
-
idleDurationMs,
|
|
430
|
-
thermalTransition: transition,
|
|
431
|
-
coolingMonotonicity,
|
|
432
|
-
baselineVariance: +(samples[0]?.variance ?? 0).toFixed(6),
|
|
433
|
-
finalVariance: +(samples[samples.length - 1]?.variance ?? 0).toFixed(6),
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// ── Internal: statistics ──────────────────────────────────────────────────────
|
|
438
|
-
|
|
439
|
-
function _mean(arr, n) {
|
|
440
|
-
let s = 0;
|
|
441
|
-
for (let i = 0; i < n; i++) s += arr[i];
|
|
442
|
-
return s / n;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function _variance(arr, n, mean) {
|
|
446
|
-
let s = 0;
|
|
447
|
-
for (let i = 0; i < n; i++) s += (arr[i] - mean) ** 2;
|
|
448
|
-
return s / n;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ── JSDoc types ───────────────────────────────────────────────────────────────
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* @typedef {object} ThermalSample
|
|
455
|
-
* @property {number} ts Unix ms timestamp of this measurement
|
|
456
|
-
* @property {number} meanMs Mean DRAM iteration time — proxy for CPU clock frequency
|
|
457
|
-
* @property {number} variance Variance of iteration times — proxy for thermal noise
|
|
458
|
-
*/
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* @typedef {object} IdleProof
|
|
462
|
-
* @property {string} chain Final SHA-256 hash in the measurement chain
|
|
463
|
-
* @property {number} samples Number of chain nodes (≥ 2 for a valid proof)
|
|
464
|
-
* @property {number} idleDurationMs Total elapsed idle time (ms)
|
|
465
|
-
* @property {string} thermalTransition 'hot_to_cold'|'cold'|'cooling'|'warming'|'sustained_hot'|'step_function'|'unknown'
|
|
466
|
-
* @property {number} coolingMonotonicity Fraction of sample pairs with decreasing variance (0–1)
|
|
467
|
-
* @property {number} baselineVariance Timing variance at idle start
|
|
468
|
-
* @property {number} finalVariance Timing variance at idle end
|
|
469
|
-
* @property {number} capturedAt Unix ms when proof was consumed (set by getProof)
|
|
470
|
-
*/
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* @typedef {object} IdleMonitor
|
|
474
|
-
* @property {() => IdleMonitor} start Register browser event listeners
|
|
475
|
-
* @property {() => IdleMonitor} stop Deregister listeners and cancel timers
|
|
476
|
-
* @property {() => IdleProof|null} getProof Consume pending proof (one-time)
|
|
477
|
-
* @property {() => string} getState Current state machine state
|
|
478
|
-
* @property {() => IdleMonitor} declareIdle Manually trigger idle (Node.js)
|
|
479
|
-
* @property {() => IdleMonitor} declareActive Manually trigger active (Node.js)
|
|
480
|
-
*/
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — Idle Attestation Collector
|
|
3
|
+
*
|
|
4
|
+
* Click farms run thousands of real devices at sustained maximum throughput —
|
|
5
|
+
* they physically cannot let a device idle. This module builds a cryptographic
|
|
6
|
+
* proof that a device experienced a genuine rest period between interactions:
|
|
7
|
+
* thermal cooling, CPU clock-scaling, and a hash-chained measurement sequence
|
|
8
|
+
* that cannot be back-filled faster than real time.
|
|
9
|
+
*
|
|
10
|
+
* Physics basis
|
|
11
|
+
* ─────────────
|
|
12
|
+
* Real device between interactions:
|
|
13
|
+
* → CPU frequency drops via DVFS (Dynamic Voltage/Frequency Scaling)
|
|
14
|
+
* → DRAM access latency rises as the front-side bus slows down
|
|
15
|
+
* → Thermal mass of die + PCB means temperature decays exponentially
|
|
16
|
+
* → Timing variance follows Newton's Law of Cooling — a smooth curve
|
|
17
|
+
*
|
|
18
|
+
* Click farm device with paused script:
|
|
19
|
+
* → CPU load drops from ~100% to ~0% INSTANTLY (OS task queue emptied)
|
|
20
|
+
* → DRAM timing shows a STEP FUNCTION, not an exponential curve
|
|
21
|
+
* → The step is economically forced: farm scripts resume within 90s
|
|
22
|
+
* to maintain throughput; real thermal settling takes minutes
|
|
23
|
+
*
|
|
24
|
+
* Hash chain
|
|
25
|
+
* ──────────
|
|
26
|
+
* Each idle sample produces a chain node:
|
|
27
|
+
* node[n].hash = SHA-256(node[n-1].hash ‖ ts ‖ meanMs ‖ variance)
|
|
28
|
+
*
|
|
29
|
+
* The chain proves samples were taken in sequence at regular intervals.
|
|
30
|
+
* N nodes at 30-second intervals = (N−1)×30s minimum elapsed time.
|
|
31
|
+
* It cannot be fabricated faster than real time without the server
|
|
32
|
+
* noticing the timing impossibility.
|
|
33
|
+
*
|
|
34
|
+
* Thermal transition taxonomy
|
|
35
|
+
* ───────────────────────────
|
|
36
|
+
* hot_to_cold → smooth exponential variance decay (genuine cooling ✓)
|
|
37
|
+
* cold → device was already at rest temperature (genuine idle ✓)
|
|
38
|
+
* cooling → mild, ongoing decay (genuine idle ✓)
|
|
39
|
+
* warming → device heating up (uncommon during idle)
|
|
40
|
+
* sustained_hot → elevated variance throughout (click farm: constant load ✗)
|
|
41
|
+
* step_function → abrupt single-interval drop (click farm: script paused ✗)
|
|
42
|
+
* unknown → insufficient samples to classify
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
46
|
+
import { bytesToHex,
|
|
47
|
+
utf8ToBytes,
|
|
48
|
+
randomBytes } from '@noble/hashes/utils';
|
|
49
|
+
|
|
50
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Minimum idle duration before issuing a proof.
|
|
53
|
+
* Farm scripts pause for < 30s to maintain throughput.
|
|
54
|
+
* This threshold creates a real economic cost: 45s idle × 1000 devices =
|
|
55
|
+
* 12.5 device-hours of forced downtime per 1000 tokens. */
|
|
56
|
+
const MIN_IDLE_MS = 45_000;
|
|
57
|
+
|
|
58
|
+
/** Sampling interval. 30s gives 3 nodes in a 90s session — enough to
|
|
59
|
+
* differentiate a cooling curve from a step function. */
|
|
60
|
+
const SAMPLE_INTERVAL_MS = 30_000;
|
|
61
|
+
|
|
62
|
+
/** Grace period after focus/visibility loss before declaring idle.
|
|
63
|
+
* Absorbs rapid tab switches and accidental blur events. */
|
|
64
|
+
const IDLE_WATCH_GRACE_MS = 5_000;
|
|
65
|
+
|
|
66
|
+
/** Mini probe buffer — 16 MB exceeds L3 cache on most consumer devices,
|
|
67
|
+
* forcing reads to DRAM. Small enough that the probe finishes in < 100ms,
|
|
68
|
+
* so we don't meaningfully disturb the idle state we're measuring. */
|
|
69
|
+
const MINI_BUFFER_MB = 16;
|
|
70
|
+
|
|
71
|
+
/** Mini probe iteration count. ~80ms total wall-clock time. */
|
|
72
|
+
const MINI_ITERATIONS = 80;
|
|
73
|
+
|
|
74
|
+
/** Variance at or below this value indicates a device at rest temperature.
|
|
75
|
+
* Calibrated from empirical measurements on idle consumer hardware. */
|
|
76
|
+
const COLD_VARIANCE_THRESHOLD = 0.003;
|
|
77
|
+
|
|
78
|
+
/** Variance above this value indicates sustained CPU load — characteristic
|
|
79
|
+
* of click farm operation (continuous task execution). */
|
|
80
|
+
const HOT_VARIANCE_THRESHOLD = 0.025;
|
|
81
|
+
|
|
82
|
+
/** If more than this fraction of the total variance drop happens in the
|
|
83
|
+
* first sample interval, we classify the transition as 'step_function'. */
|
|
84
|
+
const STEP_FUNCTION_RATIO = 0.75;
|
|
85
|
+
|
|
86
|
+
// ── State machine ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** @enum {string} */
|
|
89
|
+
const State = Object.freeze({
|
|
90
|
+
ACTIVE: 'active', // device in normal use
|
|
91
|
+
IDLE_WATCH: 'idle_watch', // focus lost, in grace period
|
|
92
|
+
IDLE_SAMPLING: 'idle_sampling', // sampling in progress, chain building
|
|
93
|
+
IDLE_COMMITTED: 'idle_committed', // proof ready to consume
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── createIdleMonitor ─────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create a stateful idle monitor for the current session.
|
|
100
|
+
*
|
|
101
|
+
* **Browser**: automatically hooks `visibilitychange` and `blur`/`focus`.
|
|
102
|
+
* Call `monitor.start()` once on page load and `monitor.stop()` on unload.
|
|
103
|
+
*
|
|
104
|
+
* **Node.js / React Native**: call `monitor.declareIdle()` and
|
|
105
|
+
* `monitor.declareActive()` manually to drive the state machine.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} [opts]
|
|
108
|
+
* @param {number} [opts.minIdleMs=45000] minimum idle ms for valid proof
|
|
109
|
+
* @param {number} [opts.sampleIntervalMs=30000] thermal sampling interval
|
|
110
|
+
* @param {string} [opts.sessionNonce] ties hash chain to this session
|
|
111
|
+
* @returns {IdleMonitor}
|
|
112
|
+
*/
|
|
113
|
+
export function createIdleMonitor(opts = {}) {
|
|
114
|
+
const {
|
|
115
|
+
minIdleMs = MIN_IDLE_MS,
|
|
116
|
+
sampleIntervalMs = SAMPLE_INTERVAL_MS,
|
|
117
|
+
sessionNonce = bytesToHex(randomBytes(8)),
|
|
118
|
+
} = opts;
|
|
119
|
+
|
|
120
|
+
// ── Mutable private state (encapsulated in closure — no global mutation) ───
|
|
121
|
+
let _state = State.ACTIVE;
|
|
122
|
+
let _idleStartMs = 0;
|
|
123
|
+
let _watchTimer = null;
|
|
124
|
+
let _sampleTimer = null;
|
|
125
|
+
let _chain = _genesisHash(sessionNonce);
|
|
126
|
+
let _samples = /** @type {ThermalSample[]} */ ([]);
|
|
127
|
+
let _pendingProof = null;
|
|
128
|
+
let _probeBuffer = null; // allocated lazily on first sample, then reused
|
|
129
|
+
|
|
130
|
+
// ── State transition: ACTIVE / IDLE_COMMITTED → IDLE_WATCH ───────────────
|
|
131
|
+
function _enterWatch() {
|
|
132
|
+
if (_state !== State.ACTIVE && _state !== State.IDLE_COMMITTED) return;
|
|
133
|
+
// Discard any unconsumed proof — a new idle cycle supersedes the old one.
|
|
134
|
+
_pendingProof = null;
|
|
135
|
+
_state = State.IDLE_WATCH;
|
|
136
|
+
_watchTimer = setTimeout(_enterSampling, IDLE_WATCH_GRACE_MS);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── State transition: IDLE_WATCH → IDLE_SAMPLING ──────────────────────────
|
|
140
|
+
function _enterSampling() {
|
|
141
|
+
_state = State.IDLE_SAMPLING;
|
|
142
|
+
_idleStartMs = Date.now();
|
|
143
|
+
_samples = [];
|
|
144
|
+
_chain = _genesisHash(`${sessionNonce}:${_idleStartMs}`);
|
|
145
|
+
|
|
146
|
+
// Take first sample immediately, then on interval
|
|
147
|
+
_tick();
|
|
148
|
+
_sampleTimer = setInterval(_tick, sampleIntervalMs);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Periodic sample tick ───────────────────────────────────────────────────
|
|
152
|
+
function _tick() {
|
|
153
|
+
// Allocate probe buffer once; reuse to avoid GC pressure every 30s
|
|
154
|
+
if (!_probeBuffer) _probeBuffer = _allocBuffer();
|
|
155
|
+
const sample = _miniProbe(_probeBuffer);
|
|
156
|
+
_samples.push(sample);
|
|
157
|
+
_chain = _chainStep(_chain, sample);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── State transition: IDLE_SAMPLING → IDLE_COMMITTED or ACTIVE ────────────
|
|
161
|
+
function _commitOrReset() {
|
|
162
|
+
clearTimeout(_watchTimer);
|
|
163
|
+
clearInterval(_sampleTimer);
|
|
164
|
+
_watchTimer = null;
|
|
165
|
+
_sampleTimer = null;
|
|
166
|
+
|
|
167
|
+
const idleDurationMs = Date.now() - _idleStartMs;
|
|
168
|
+
const hasEnoughTime = idleDurationMs >= minIdleMs;
|
|
169
|
+
const hasEnoughSamples = _samples.length >= 2;
|
|
170
|
+
|
|
171
|
+
if (_state === State.IDLE_SAMPLING && hasEnoughTime && hasEnoughSamples) {
|
|
172
|
+
_pendingProof = _buildProof(_chain, _samples, idleDurationMs);
|
|
173
|
+
_state = State.IDLE_COMMITTED;
|
|
174
|
+
} else {
|
|
175
|
+
_reset();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Reset to ACTIVE ────────────────────────────────────────────────────────
|
|
180
|
+
function _reset() {
|
|
181
|
+
_state = State.ACTIVE;
|
|
182
|
+
_idleStartMs = 0;
|
|
183
|
+
_samples = [];
|
|
184
|
+
_pendingProof = null;
|
|
185
|
+
_chain = _genesisHash(sessionNonce);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Browser event handlers ─────────────────────────────────────────────────
|
|
189
|
+
const _onHide = () => _enterWatch();
|
|
190
|
+
const _onShow = () => { if (_state !== State.ACTIVE) _commitOrReset(); };
|
|
191
|
+
const _onVisibilityChange = () => (document.hidden ? _onHide() : _onShow());
|
|
192
|
+
|
|
193
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/** Register browser event listeners. No-op in non-browser environments. */
|
|
196
|
+
function start() {
|
|
197
|
+
if (typeof document !== 'undefined') {
|
|
198
|
+
document.addEventListener('visibilitychange', _onVisibilityChange);
|
|
199
|
+
}
|
|
200
|
+
if (typeof window !== 'undefined') {
|
|
201
|
+
window.addEventListener('blur', _onHide);
|
|
202
|
+
window.addEventListener('focus', _onShow);
|
|
203
|
+
}
|
|
204
|
+
return api;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Deregister browser event listeners and cancel pending timers. */
|
|
208
|
+
function stop() {
|
|
209
|
+
clearTimeout(_watchTimer);
|
|
210
|
+
clearInterval(_sampleTimer);
|
|
211
|
+
if (typeof document !== 'undefined') {
|
|
212
|
+
document.removeEventListener('visibilitychange', _onVisibilityChange);
|
|
213
|
+
}
|
|
214
|
+
if (typeof window !== 'undefined') {
|
|
215
|
+
window.removeEventListener('blur', _onHide);
|
|
216
|
+
window.removeEventListener('focus', _onShow);
|
|
217
|
+
}
|
|
218
|
+
return api;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Manual idle declaration for Node.js or non-browser environments. */
|
|
222
|
+
function declareIdle() { _enterWatch(); return api; }
|
|
223
|
+
|
|
224
|
+
/** Manual active declaration for Node.js or non-browser environments. */
|
|
225
|
+
function declareActive() { _commitOrReset(); return api; }
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Consume the pending idle proof — one-time read that resets the monitor.
|
|
229
|
+
* Returns null if no valid proof is ready (device hasn't been idle long enough).
|
|
230
|
+
*
|
|
231
|
+
* @returns {IdleProof|null}
|
|
232
|
+
*/
|
|
233
|
+
function getProof() {
|
|
234
|
+
if (_state !== State.IDLE_COMMITTED || !_pendingProof) return null;
|
|
235
|
+
const proof = { ..._pendingProof, capturedAt: Date.now() };
|
|
236
|
+
_reset();
|
|
237
|
+
return proof;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Current state machine state — useful for debugging and tests. */
|
|
241
|
+
function getState() { return _state; }
|
|
242
|
+
|
|
243
|
+
const api = { start, stop, getProof, getState, declareIdle, declareActive };
|
|
244
|
+
return api;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── analyseIdleProof ──────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Validate the physical plausibility of an IdleProof before embedding it in
|
|
251
|
+
* an engagement token. Returns advisory warnings without rejecting outright —
|
|
252
|
+
* the server-side verifier makes the final call.
|
|
253
|
+
*
|
|
254
|
+
* @param {IdleProof} proof
|
|
255
|
+
* @returns {{ plausible: boolean, reason?: string, warnings: string[] }}
|
|
256
|
+
*/
|
|
257
|
+
export function analyseIdleProof(proof) {
|
|
258
|
+
if (!proof) return { plausible: false, reason: 'no_proof', warnings: [] };
|
|
259
|
+
|
|
260
|
+
const warnings = [];
|
|
261
|
+
|
|
262
|
+
if (proof.idleDurationMs < MIN_IDLE_MS) {
|
|
263
|
+
return { plausible: false, reason: 'idle_too_short', warnings };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (proof.samples < 2) {
|
|
267
|
+
return { plausible: false, reason: 'insufficient_chain_samples', warnings };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!proof.chain || proof.chain.length !== 64) {
|
|
271
|
+
return { plausible: false, reason: 'malformed_chain_hash', warnings };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (proof.thermalTransition === 'step_function') {
|
|
275
|
+
warnings.push('abrupt_cpu_transition_detected');
|
|
276
|
+
}
|
|
277
|
+
if (proof.thermalTransition === 'sustained_hot') {
|
|
278
|
+
warnings.push('no_thermal_decay_observed');
|
|
279
|
+
}
|
|
280
|
+
if (proof.coolingMonotonicity < 0.3 && proof.samples >= 3) {
|
|
281
|
+
warnings.push('non_monotonic_cooling_curve');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { plausible: true, warnings };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Internal: mini DRAM probe ─────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Lightweight DRAM probe: 16 MB buffer, 80 iterations, < 100ms.
|
|
291
|
+
* Returns mean iteration time (reflects CPU clock frequency) and
|
|
292
|
+
* variance (reflects thermal noise intensity).
|
|
293
|
+
*
|
|
294
|
+
* Exported for unit testing — not part of the public API surface.
|
|
295
|
+
*
|
|
296
|
+
* @param {Float64Array} buf pre-allocated cache-busting buffer
|
|
297
|
+
* @returns {ThermalSample}
|
|
298
|
+
*/
|
|
299
|
+
export function _miniProbe(buf) {
|
|
300
|
+
const pass = _calibratePass(buf);
|
|
301
|
+
const timings = new Float64Array(MINI_ITERATIONS);
|
|
302
|
+
let dummy = 0;
|
|
303
|
+
|
|
304
|
+
for (let i = 0; i < MINI_ITERATIONS; i++) {
|
|
305
|
+
const t0 = performance.now();
|
|
306
|
+
for (let j = 0; j < pass; j++) dummy += buf[j];
|
|
307
|
+
timings[i] = performance.now() - t0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Prevent dead-code elimination of the memory reads
|
|
311
|
+
if (dummy === 0) buf[0] = 1;
|
|
312
|
+
|
|
313
|
+
const mean = _mean(timings, MINI_ITERATIONS);
|
|
314
|
+
const variance = _variance(timings, MINI_ITERATIONS, mean);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
ts: Date.now(),
|
|
318
|
+
meanMs: +mean.toFixed(4),
|
|
319
|
+
variance: +variance.toFixed(6),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function _allocBuffer() {
|
|
324
|
+
const elements = (MINI_BUFFER_MB * 1024 * 1024) / 8;
|
|
325
|
+
try {
|
|
326
|
+
const buf = new Float64Array(elements);
|
|
327
|
+
const stride = 64 / 8; // one element per 64-byte cache line
|
|
328
|
+
for (let i = 0; i < elements; i += stride) buf[i] = i;
|
|
329
|
+
return buf;
|
|
330
|
+
} catch {
|
|
331
|
+
// Memory-constrained fallback — smaller buffer means weaker signal
|
|
332
|
+
return new Float64Array(8_192);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function _calibratePass(buf) {
|
|
337
|
+
// Dynamically size the pass so each iteration takes ~1ms wall-clock.
|
|
338
|
+
// This self-calibrates across device classes (desktop, mobile, low-end).
|
|
339
|
+
const target = 1.0; // ms
|
|
340
|
+
let n = Math.min(50_000, buf.length);
|
|
341
|
+
let dummy = 0;
|
|
342
|
+
|
|
343
|
+
// Warm-up (ensures first measurement isn't cold-start biased)
|
|
344
|
+
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
345
|
+
|
|
346
|
+
const t0 = performance.now();
|
|
347
|
+
for (let i = 0; i < n; i++) dummy += buf[i];
|
|
348
|
+
const elapsed = performance.now() - t0;
|
|
349
|
+
if (dummy === 0) buf[0] = 1;
|
|
350
|
+
|
|
351
|
+
return elapsed > 0
|
|
352
|
+
? Math.min(buf.length, Math.round(n * target / elapsed))
|
|
353
|
+
: n;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Internal: hash chain ──────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
function _genesisHash(seed) {
|
|
359
|
+
return bytesToHex(sha256(utf8ToBytes(`pulse:idle:genesis:${seed}`)));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function _chainStep(prevHex, sample) {
|
|
363
|
+
// Each node commits to: previous state, exact timestamp, CPU freq proxy, thermal noise
|
|
364
|
+
const input = `${prevHex}:${sample.ts}:${sample.meanMs}:${sample.variance}`;
|
|
365
|
+
return bytesToHex(sha256(utf8ToBytes(input)));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Internal: thermal classification ─────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Classify the thermal transition from an ordered sequence of samples.
|
|
372
|
+
*
|
|
373
|
+
* The key discriminator is whether the variance follows a smooth exponential
|
|
374
|
+
* decay (genuine cooling) or drops abruptly in one interval (farm script pause).
|
|
375
|
+
*
|
|
376
|
+
* @param {ThermalSample[]} samples
|
|
377
|
+
* @returns {{ transition: string, coolingMonotonicity: number }}
|
|
378
|
+
*/
|
|
379
|
+
function _classifyThermal(samples) {
|
|
380
|
+
if (samples.length < 2) {
|
|
381
|
+
return { transition: 'unknown', coolingMonotonicity: 0 };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const variances = samples.map(s => s.variance);
|
|
385
|
+
const first = variances[0];
|
|
386
|
+
const last = variances[variances.length - 1];
|
|
387
|
+
|
|
388
|
+
// Cooling monotonicity: fraction of consecutive pairs where variance decreased
|
|
389
|
+
let decreasingPairs = 0;
|
|
390
|
+
for (let i = 1; i < variances.length; i++) {
|
|
391
|
+
if (variances[i] < variances[i - 1]) decreasingPairs++;
|
|
392
|
+
}
|
|
393
|
+
const coolingMonotonicity = +(decreasingPairs / (variances.length - 1)).toFixed(3);
|
|
394
|
+
|
|
395
|
+
// Step function detection: > STEP_FUNCTION_RATIO of total drop in first interval
|
|
396
|
+
if (variances.length >= 3) {
|
|
397
|
+
const firstDrop = Math.max(0, first - variances[1]);
|
|
398
|
+
const totalDrop = Math.max(0, first - last);
|
|
399
|
+
const isSignificantDrop = totalDrop > first * 0.15; // must be >15% absolute drop
|
|
400
|
+
const stepRatio = totalDrop > 1e-9 ? firstDrop / totalDrop : 0;
|
|
401
|
+
|
|
402
|
+
if (isSignificantDrop && stepRatio > STEP_FUNCTION_RATIO) {
|
|
403
|
+
return { transition: 'step_function', coolingMonotonicity };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Classify by absolute variance levels and direction
|
|
408
|
+
if (first < COLD_VARIANCE_THRESHOLD) {
|
|
409
|
+
return { transition: 'cold', coolingMonotonicity };
|
|
410
|
+
}
|
|
411
|
+
if (last > first * 1.10) {
|
|
412
|
+
return { transition: 'warming', coolingMonotonicity };
|
|
413
|
+
}
|
|
414
|
+
if (first > HOT_VARIANCE_THRESHOLD && last > HOT_VARIANCE_THRESHOLD * 0.85) {
|
|
415
|
+
return { transition: 'sustained_hot', coolingMonotonicity };
|
|
416
|
+
}
|
|
417
|
+
if ((first - last) / (first + 1e-9) > 0.12 && coolingMonotonicity >= 0.5) {
|
|
418
|
+
return { transition: 'hot_to_cold', coolingMonotonicity };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { transition: 'cooling', coolingMonotonicity };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function _buildProof(chain, samples, idleDurationMs) {
|
|
425
|
+
const { transition, coolingMonotonicity } = _classifyThermal(samples);
|
|
426
|
+
return {
|
|
427
|
+
chain,
|
|
428
|
+
samples: samples.length,
|
|
429
|
+
idleDurationMs,
|
|
430
|
+
thermalTransition: transition,
|
|
431
|
+
coolingMonotonicity,
|
|
432
|
+
baselineVariance: +(samples[0]?.variance ?? 0).toFixed(6),
|
|
433
|
+
finalVariance: +(samples[samples.length - 1]?.variance ?? 0).toFixed(6),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Internal: statistics ──────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
function _mean(arr, n) {
|
|
440
|
+
let s = 0;
|
|
441
|
+
for (let i = 0; i < n; i++) s += arr[i];
|
|
442
|
+
return s / n;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function _variance(arr, n, mean) {
|
|
446
|
+
let s = 0;
|
|
447
|
+
for (let i = 0; i < n; i++) s += (arr[i] - mean) ** 2;
|
|
448
|
+
return s / n;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── JSDoc types ───────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @typedef {object} ThermalSample
|
|
455
|
+
* @property {number} ts Unix ms timestamp of this measurement
|
|
456
|
+
* @property {number} meanMs Mean DRAM iteration time — proxy for CPU clock frequency
|
|
457
|
+
* @property {number} variance Variance of iteration times — proxy for thermal noise
|
|
458
|
+
*/
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* @typedef {object} IdleProof
|
|
462
|
+
* @property {string} chain Final SHA-256 hash in the measurement chain
|
|
463
|
+
* @property {number} samples Number of chain nodes (≥ 2 for a valid proof)
|
|
464
|
+
* @property {number} idleDurationMs Total elapsed idle time (ms)
|
|
465
|
+
* @property {string} thermalTransition 'hot_to_cold'|'cold'|'cooling'|'warming'|'sustained_hot'|'step_function'|'unknown'
|
|
466
|
+
* @property {number} coolingMonotonicity Fraction of sample pairs with decreasing variance (0–1)
|
|
467
|
+
* @property {number} baselineVariance Timing variance at idle start
|
|
468
|
+
* @property {number} finalVariance Timing variance at idle end
|
|
469
|
+
* @property {number} capturedAt Unix ms when proof was consumed (set by getProof)
|
|
470
|
+
*/
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @typedef {object} IdleMonitor
|
|
474
|
+
* @property {() => IdleMonitor} start Register browser event listeners
|
|
475
|
+
* @property {() => IdleMonitor} stop Deregister listeners and cancel timers
|
|
476
|
+
* @property {() => IdleProof|null} getProof Consume pending proof (one-time)
|
|
477
|
+
* @property {() => string} getState Current state machine state
|
|
478
|
+
* @property {() => IdleMonitor} declareIdle Manually trigger idle (Node.js)
|
|
479
|
+
* @property {() => IdleMonitor} declareActive Manually trigger active (Node.js)
|
|
480
|
+
*/
|