@svrnsec/pulse 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@svrnsec/pulse",
3
- "version": "0.4.0",
4
- "description": "Physical Turing Test — Hardware-Biological Symmetry Protocol distinguishing real consumer devices from sanitised datacenter VMs.",
3
+ "version": "0.5.0",
4
+ "description": "Physical Turing Test — Idle attestation, population-level Sybil detection, and engagement tokens that defeat click farms at the physics layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Aaron Miller",
@@ -71,6 +71,18 @@
71
71
  },
72
72
  "./enf": {
73
73
  "import": "./src/collector/enf.js"
74
+ },
75
+ "./idle": {
76
+ "import": "./src/collector/idleAttestation.js",
77
+ "node": "./src/collector/idleAttestation.js"
78
+ },
79
+ "./population": {
80
+ "import": "./src/analysis/populationEntropy.js",
81
+ "node": "./src/analysis/populationEntropy.js"
82
+ },
83
+ "./engage": {
84
+ "import": "./src/proof/engagementToken.js",
85
+ "node": "./src/proof/engagementToken.js"
74
86
  }
75
87
  },
76
88
  "main": "dist/pulse.cjs.js",
@@ -132,7 +144,13 @@
132
144
  "react-native",
133
145
  "mobile-security",
134
146
  "enf-detection",
135
- "cli"
147
+ "cli",
148
+ "click-farm-detection",
149
+ "idle-attestation",
150
+ "engagement-token",
151
+ "sybil-detection",
152
+ "invalid-traffic",
153
+ "proof-of-idle"
136
154
  ],
137
155
  "engines": {
138
156
  "node": ">=18.0.0"
@@ -0,0 +1,403 @@
1
+ /**
2
+ * @svrnsec/pulse — Population Entropy Analysis
3
+ *
4
+ * Server-side Sybil detector: given N engagement tokens from the same cohort
5
+ * (e.g., all ad clicks in a 10-minute window), determine whether they
6
+ * represent independently-operated consumer devices or a coordinated farm.
7
+ *
8
+ * Why per-token verification is necessary but not sufficient
9
+ * ──────────────────────────────────────────────────────────
10
+ * A single token from a farm device may pass every individual check:
11
+ * - Real hardware ✓ (it is a real phone)
12
+ * - Valid idle proof ✓ (45+ seconds of forced downtime)
13
+ * - Plausible entropy ✓ (within normal ranges)
14
+ *
15
+ * But 1,000 farm devices in one warehouse share inescapable physical context:
16
+ * - Same ambient temperature → correlated DRAM timing variance
17
+ * - Same power circuit → near-identical ENF phase deviation
18
+ * - Coordinator dispatch → rhythmic submission timestamp patterns
19
+ * - Same operational pattern → homogeneous thermal transitions
20
+ * - Economic throughput limit → idle durations cluster at the minimum
21
+ *
22
+ * Population analysis exposes these systematic correlations that are
23
+ * individually invisible but statistically unmistakable at scale.
24
+ *
25
+ * Statistical tests (5)
26
+ * ─────────────────────
27
+ * 1. Timestamp rhythm — autocorrelation of inter-arrival times
28
+ * 2. Entropy dispersion — coefficient of variation across physics scores
29
+ * 3. Thermal diversity — Shannon entropy of thermal transition labels
30
+ * 4. Idle duration cluster — fraction of durations near the minimum viable
31
+ * 5. ENF phase coherence — variance of measured grid frequency deviations
32
+ *
33
+ * Scoring
34
+ * ───────
35
+ * Each test returns a sybilScore in [0, 100] where 100 = maximally suspicious.
36
+ * The population sybilScore is a weighted average of the five tests.
37
+ * An authentic cohort scores < 40; a clear farm scores > 70.
38
+ */
39
+
40
+ // ── analysePopulation ─────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Analyse a cohort of parsed engagement tokens for population-level Sybil signals.
44
+ *
45
+ * @param {ParsedEngagementToken[]} tokens decoded (not yet signature-verified) tokens
46
+ * @param {object} [opts]
47
+ * @param {number} [opts.minSample=5] minimum cohort size for a meaningful verdict
48
+ * @returns {PopulationVerdict}
49
+ */
50
+ export function analysePopulation(tokens, opts = {}) {
51
+ const { minSample = 5 } = opts;
52
+
53
+ if (!Array.isArray(tokens) || tokens.length < minSample) {
54
+ return _insufficientSample(tokens?.length ?? 0, minSample);
55
+ }
56
+
57
+ const rhythmResult = testTimestampRhythm(tokens);
58
+ const dispersion = testEntropyDispersion(tokens);
59
+ const thermalDiv = testThermalDiversity(tokens);
60
+ const idlePlaus = testIdlePlausibility(tokens);
61
+ const enfCoherence = testEnfCoherence(tokens);
62
+
63
+ // Weighted aggregate (weights reflect signal reliability and discriminative power)
64
+ const sybilScore = Math.round(
65
+ rhythmResult.score * 0.25 +
66
+ dispersion.score * 0.25 +
67
+ thermalDiv.score * 0.20 +
68
+ idlePlaus.score * 0.20 +
69
+ enfCoherence.score * 0.10
70
+ );
71
+
72
+ // Confidence scales with cohort size — small cohorts can't make strong claims
73
+ const confidence = +Math.min(1.0, tokens.length / 50).toFixed(3);
74
+
75
+ const flags = [
76
+ rhythmResult.score > 60 && 'RHYTHMIC_DISPATCH',
77
+ dispersion.score > 60 && 'HOMOGENEOUS_HARDWARE',
78
+ thermalDiv.score > 60 && 'UNIFORM_THERMAL_PATTERN',
79
+ idlePlaus.score > 60 && 'SYNTHETIC_IDLE_CLUSTERING',
80
+ enfCoherence.score > 60 && 'CO_LOCATED_DEVICES',
81
+ ].filter(Boolean);
82
+
83
+ return {
84
+ authentic: sybilScore < 40,
85
+ sybilScore,
86
+ confidence,
87
+ tests: {
88
+ timestampRhythm: rhythmResult,
89
+ entropyDispersion: dispersion,
90
+ thermalDiversity: thermalDiv,
91
+ idlePlausibility: idlePlaus,
92
+ enfCoherence,
93
+ },
94
+ flags,
95
+ summary: _formatSummary(sybilScore, flags, tokens.length),
96
+ };
97
+ }
98
+
99
+ // ── Test 1: Timestamp Rhythm ──────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Farm coordinators dispatch tokens in batches at scheduler-driven intervals.
103
+ * A coordinator sending 1,000 tokens at 120-second intervals produces strong
104
+ * positive autocorrelation in the inter-arrival time series.
105
+ *
106
+ * Real users click independently; their inter-arrival times are random.
107
+ *
108
+ * @param {ParsedEngagementToken[]} tokens
109
+ * @returns {{ score: number, ac1: number, ac2: number, detail: string }}
110
+ */
111
+ export function testTimestampRhythm(tokens) {
112
+ const timestamps = tokens.map(t => t.iat).filter(Number.isFinite).sort((a, b) => a - b);
113
+ if (timestamps.length < 4) {
114
+ return { score: 0, ac1: 0, ac2: 0, detail: 'insufficient_samples' };
115
+ }
116
+
117
+ // Inter-arrival times in milliseconds
118
+ const deltas = [];
119
+ for (let i = 1; i < timestamps.length; i++) {
120
+ deltas.push(timestamps[i] - timestamps[i - 1]);
121
+ }
122
+
123
+ const n = deltas.length;
124
+ const mean = _mean(deltas);
125
+ const v0 = _variance(deltas, mean);
126
+
127
+ // Zero-variance: perfectly uniform dispatch — maximally suspicious
128
+ if (v0 < 1e-6) {
129
+ return { score: 100, ac1: 1, ac2: 1, detail: 'perfectly_uniform_dispatch' };
130
+ }
131
+
132
+ const ac1 = _autocorrAtLag(deltas, mean, v0, 1);
133
+ const ac2 = _autocorrAtLag(deltas, mean, v0, 2);
134
+
135
+ // Use the more suspicious of the two lags
136
+ const signal = Math.max(Math.abs(ac1), Math.abs(ac2));
137
+
138
+ // Threshold calibration:
139
+ // |ac| < 0.15 → independent (real users)
140
+ // |ac| > 0.40 → rhythmic (farm coordinator)
141
+ const score = Math.min(100, Math.max(0,
142
+ ((signal - 0.15) / (0.40 - 0.15)) * 100
143
+ ));
144
+
145
+ return {
146
+ score: Math.round(score),
147
+ ac1: +ac1.toFixed(3),
148
+ ac2: +ac2.toFixed(3),
149
+ detail: signal > 0.40 ? 'rhythmic_dispatch_detected' : 'normal_variance',
150
+ };
151
+ }
152
+
153
+ // ── Test 2: Entropy Dispersion ────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Real users bring diverse device histories: different ages, temperatures,
157
+ * load profiles. Their physics scores (entropy, jitter) span a wide range.
158
+ *
159
+ * Farm devices in one warehouse are typically the same model, same ambient
160
+ * temperature, running the same workload. Their scores cluster tightly.
161
+ *
162
+ * @param {ParsedEngagementToken[]} tokens
163
+ * @returns {{ score: number, cv: number, mean: number, detail: string }}
164
+ */
165
+ export function testEntropyDispersion(tokens) {
166
+ const scores = tokens.map(t => t.hw?.ent).filter(v => v != null && Number.isFinite(v) && v > 0);
167
+ if (scores.length < 3) {
168
+ return { score: 0, cv: 0, mean: 0, detail: 'no_entropy_data' };
169
+ }
170
+
171
+ const mean = _mean(scores);
172
+ if (mean < 1e-9) return { score: 50, cv: 0, mean: 0, detail: 'zero_entropy_scores' };
173
+
174
+ const stddev = Math.sqrt(_variance(scores, mean));
175
+ const cv = stddev / mean; // Coefficient of Variation
176
+
177
+ // Calibration:
178
+ // CV > 0.12 → diverse hardware (real users)
179
+ // CV < 0.04 → homogeneous (farm devices)
180
+ const score = Math.min(100, Math.max(0,
181
+ ((0.10 - cv) / (0.10 - 0.03)) * 100
182
+ ));
183
+
184
+ return {
185
+ score: Math.round(score),
186
+ cv: +cv.toFixed(4),
187
+ mean: +mean.toFixed(3),
188
+ detail: cv < 0.04 ? 'homogeneous_hardware_detected' : 'normal_diversity',
189
+ };
190
+ }
191
+
192
+ // ── Test 3: Thermal Diversity ─────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Real users encounter devices in diverse thermal states throughout the day:
196
+ * cold morning wake-ups, warm post-commute picks, hot post-gaming sessions.
197
+ * The population distribution across transition labels should be broad.
198
+ *
199
+ * Farm devices maintain constant operational throughput. Their transitions
200
+ * cluster at 'step_function' (scripted pause) or 'sustained_hot' (constant load).
201
+ *
202
+ * @param {ParsedEngagementToken[]} tokens
203
+ * @returns {{ score: number, suspiciousRatio: number, distribution: object, detail: string }}
204
+ */
205
+ export function testThermalDiversity(tokens) {
206
+ const transitions = tokens.map(t => t.idle?.therm).filter(Boolean);
207
+ if (transitions.length < 3) {
208
+ return { score: 0, suspiciousRatio: 0, distribution: {}, detail: 'no_thermal_data' };
209
+ }
210
+
211
+ const counts = {};
212
+ for (const t of transitions) counts[t] = (counts[t] ?? 0) + 1;
213
+ const total = transitions.length;
214
+
215
+ // Farm-indicator transitions
216
+ const suspicious = (counts.step_function ?? 0) + (counts.sustained_hot ?? 0);
217
+ const suspiciousRatio = suspicious / total;
218
+
219
+ // Shannon entropy of the transition label distribution
220
+ const probs = Object.values(counts).map(c => c / total);
221
+ const popEntropy = _shannonEntropy(probs);
222
+ const maxEntropy = Math.log2(6); // 6 possible labels
223
+
224
+ // Combined: raw suspicious ratio + low population entropy both indicate farm
225
+ const ratioScore = Math.min(100, suspiciousRatio * 100);
226
+ const entropyScore = Math.min(80, (1 - popEntropy / maxEntropy) * 80);
227
+ const score = Math.round(0.6 * ratioScore + 0.4 * entropyScore);
228
+
229
+ const detail = suspiciousRatio > 0.50
230
+ ? 'majority_farm_thermal_transitions'
231
+ : popEntropy < maxEntropy * 0.40
232
+ ? 'low_thermal_label_diversity'
233
+ : 'normal_distribution';
234
+
235
+ return {
236
+ score,
237
+ suspiciousRatio: +suspiciousRatio.toFixed(3),
238
+ distribution: Object.fromEntries(
239
+ Object.entries(counts).map(([k, v]) => [k, +(v / total).toFixed(3)])
240
+ ),
241
+ detail,
242
+ };
243
+ }
244
+
245
+ // ── Test 4: Idle Duration Plausibility ───────────────────────────────────────
246
+
247
+ /**
248
+ * Real humans idle unpredictably: 5 minutes for a notification, an hour for
249
+ * lunch, 8 hours overnight. The distribution is broad and roughly log-normal.
250
+ *
251
+ * Farm scripts, constrained by throughput targets, idle for exactly the
252
+ * minimum duration required to pass attestation (~45–90s). The population
253
+ * reveals a tight cluster just above the minimum viable threshold.
254
+ *
255
+ * @param {ParsedEngagementToken[]} tokens
256
+ * @returns {{ score: number, clusterRatio: number, medianMs: number, detail: string }}
257
+ */
258
+ export function testIdlePlausibility(tokens) {
259
+ const durations = tokens.map(t => t.idle?.dMs).filter(d => d != null && d > 0);
260
+ if (durations.length < 3) {
261
+ return { score: 0, clusterRatio: 0, medianMs: 0, detail: 'no_idle_data' };
262
+ }
263
+
264
+ // The "barely made it" window: idle just long enough to pass, then immediately resume
265
+ const CLUSTER_LO = 45_000; // MIN_IDLE_MS
266
+ const CLUSTER_HI = 100_000; // 2.2× minimum — beyond this, farms lose too much throughput
267
+
268
+ const inCluster = durations.filter(d => d >= CLUSTER_LO && d <= CLUSTER_HI).length;
269
+ const clusterRatio = inCluster / durations.length;
270
+
271
+ // Low coefficient of variation = durations are suspiciously uniform
272
+ const mean = _mean(durations);
273
+ const stddev = Math.sqrt(_variance(durations, mean));
274
+ const cv = stddev / (mean + 1);
275
+
276
+ // Scoring: high cluster ratio + low duration CV = farm fingerprint
277
+ const clusterScore = Math.min(70, Math.max(0, (clusterRatio - 0.40) / (0.80 - 0.40)) * 70);
278
+ const cvScore = Math.min(30, Math.max(0, (0.50 - cv) / (0.50 - 0.10)) * 30);
279
+ const score = Math.round(clusterScore + cvScore);
280
+
281
+ return {
282
+ score,
283
+ clusterRatio: +clusterRatio.toFixed(3),
284
+ medianMs: _median(durations),
285
+ detail: clusterRatio > 0.70 ? 'idle_duration_clustered_at_minimum' : 'normal_distribution',
286
+ };
287
+ }
288
+
289
+ // ── Test 5: ENF Phase Coherence ───────────────────────────────────────────────
290
+
291
+ /**
292
+ * All devices on the same electrical circuit share the same ENF phase.
293
+ * A 1,000-device farm in a single warehouse shares one power feed — their
294
+ * ENF frequency deviations are nearly identical.
295
+ *
296
+ * Real users across a city are on separate circuits with independent phase
297
+ * evolution; their deviations spread naturally over a measurable range.
298
+ *
299
+ * @param {ParsedEngagementToken[]} tokens
300
+ * @returns {{ score: number, phaseVariance: number|null, detail: string }}
301
+ */
302
+ export function testEnfCoherence(tokens) {
303
+ const deviations = tokens
304
+ .map(t => t.hw?.enfDev)
305
+ .filter(d => d != null && Number.isFinite(d));
306
+
307
+ if (deviations.length < 4) {
308
+ return { score: 0, phaseVariance: null, detail: 'enf_unavailable_or_insufficient' };
309
+ }
310
+
311
+ const mean = _mean(deviations);
312
+ const variance = _variance(deviations, mean);
313
+
314
+ // Calibration (Hz² variance):
315
+ // variance > 0.0002 → diverse circuits (real users across a city)
316
+ // variance < 0.00002 → same rack, same feed (co-located farm)
317
+ const score = Math.round(Math.min(100, Math.max(0,
318
+ (1 - variance / 0.0002) * 100
319
+ )));
320
+
321
+ return {
322
+ score,
323
+ phaseVariance: +variance.toFixed(8),
324
+ detail: variance < 0.00002 ? 'co_located_devices_same_circuit' : 'normal_phase_spread',
325
+ };
326
+ }
327
+
328
+ // ── Internal helpers ──────────────────────────────────────────────────────────
329
+
330
+ function _mean(arr) {
331
+ if (!arr.length) return 0;
332
+ return arr.reduce((s, v) => s + v, 0) / arr.length;
333
+ }
334
+
335
+ function _variance(arr, mean) {
336
+ if (arr.length < 2) return 0;
337
+ return arr.reduce((s, v) => s + (v - mean) ** 2, 0) / arr.length;
338
+ }
339
+
340
+ function _median(arr) {
341
+ const s = [...arr].sort((a, b) => a - b);
342
+ const mid = Math.floor(s.length / 2);
343
+ return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
344
+ }
345
+
346
+ function _autocorrAtLag(data, mean, variance, lag) {
347
+ const n = data.length;
348
+ if (lag >= n || variance < 1e-12) return 0;
349
+ let cov = 0;
350
+ for (let i = 0; i < n - lag; i++) {
351
+ cov += (data[i] - mean) * (data[i + lag] - mean);
352
+ }
353
+ return cov / ((n - lag) * variance);
354
+ }
355
+
356
+ function _shannonEntropy(probs) {
357
+ return -probs
358
+ .filter(p => p > 0)
359
+ .reduce((s, p) => s + p * Math.log2(p), 0);
360
+ }
361
+
362
+ function _formatSummary(score, flags, n) {
363
+ const risk = score >= 80 ? 'HIGH_RISK'
364
+ : score >= 50 ? 'SUSPICIOUS'
365
+ : score >= 30 ? 'MARGINAL'
366
+ : 'CLEAN';
367
+ const flagStr = flags.length ? ` flags=[${flags.join(',')}]` : '';
368
+ return `${risk}: sybilScore=${score}/100 n=${n}${flagStr}`;
369
+ }
370
+
371
+ function _insufficientSample(n, required) {
372
+ return {
373
+ authentic: true, // default to not blocking on insufficient data
374
+ sybilScore: 0,
375
+ confidence: 0,
376
+ tests: {},
377
+ flags: [],
378
+ summary: `INSUFFICIENT_SAMPLE: ${n}/${required} tokens required for analysis`,
379
+ };
380
+ }
381
+
382
+ // ── JSDoc types ───────────────────────────────────────────────────────────────
383
+
384
+ /**
385
+ * @typedef {object} ParsedEngagementToken
386
+ * @property {number} iat issued-at Unix ms
387
+ * @property {object} [idle] idle proof summary
388
+ * @property {number|null} [idle.dMs] idle duration ms
389
+ * @property {string} [idle.therm] thermal transition label
390
+ * @property {object} [hw] hardware signal summary
391
+ * @property {number} [hw.ent] normalized entropy score (0–1)
392
+ * @property {number} [hw.enfDev] ENF frequency deviation (Hz)
393
+ */
394
+
395
+ /**
396
+ * @typedef {object} PopulationVerdict
397
+ * @property {boolean} authentic true if population appears legitimate (sybilScore < 40)
398
+ * @property {number} sybilScore 0–100 suspicion score (higher = more suspicious)
399
+ * @property {number} confidence 0–1 confidence in verdict (scales with cohort size)
400
+ * @property {object} tests per-test result objects
401
+ * @property {string[]} flags fired detection flags
402
+ * @property {string} summary one-line human-readable summary
403
+ */
@@ -119,6 +119,31 @@ export function computeTrustScore(payload, extended = {}) {
119
119
  bonuses.push({ signal: 'gpu+dram', reason: 'GPU thermal + DRAM refresh both confirmed', pts: 3 });
120
120
  }
121
121
 
122
+ // ── 6. Idle attestation (bonus/penalty from engagement token) ─────────────
123
+ // The idle proof is optional — only present when createEngagementToken() is used.
124
+ // Genuine thermal cooling proves the device was not running continuous load.
125
+ const idleProof = extended.idle ?? payload?.signals?.idle ?? null;
126
+ if (idleProof) {
127
+ const { thermalTransition, coolingMonotonicity, samples } = idleProof;
128
+
129
+ if (thermalTransition === 'hot_to_cold' || thermalTransition === 'cold') {
130
+ bonuses.push({ signal: 'idle', reason: 'Genuine thermal cooling confirmed between interactions', pts: 5 });
131
+ if (coolingMonotonicity >= 0.8 && samples >= 3) {
132
+ bonuses.push({ signal: 'idle', reason: 'Smooth exponential cooling curve — consistent with Newton cooling', pts: 3 });
133
+ }
134
+ } else if (thermalTransition === 'cooling') {
135
+ bonuses.push({ signal: 'idle', reason: 'Mild thermal decay during idle period', pts: 2 });
136
+ } else if (thermalTransition === 'step_function') {
137
+ // Abrupt variance drop: characteristic of script pause, not natural idle
138
+ hardCap = Math.min(hardCap, 65);
139
+ penalties.push({ signal: 'idle', reason: 'Step-function thermal transition (click farm script pause pattern)', cap: 65 });
140
+ } else if (thermalTransition === 'sustained_hot') {
141
+ // No cooling at all: device was under constant load throughout "idle"
142
+ hardCap = Math.min(hardCap, 60);
143
+ penalties.push({ signal: 'idle', reason: 'No thermal decay during idle — sustained load pattern', cap: 60 });
144
+ }
145
+ }
146
+
122
147
  // ── Raw score ─────────────────────────────────────────────────────────────
123
148
  const bonusPts = bonuses.reduce((s, b) => s + b.pts, 0);
124
149
  const raw = Math.min(100,
@@ -0,0 +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
+ // ── Public API ────────────────────────────────────────────────────────────
193
+
194
+ /** Register browser event listeners. No-op in non-browser environments. */
195
+ function start() {
196
+ if (typeof document !== 'undefined') {
197
+ document.addEventListener('visibilitychange',
198
+ () => (document.hidden ? _onHide() : _onShow()));
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', _onHide);
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
+ */
@@ -0,0 +1,394 @@
1
+ /**
2
+ * @svrnsec/pulse — Engagement Token
3
+ *
4
+ * A short-lived, physics-backed cryptographic token that proves a specific
5
+ * engagement event (click, view, share, purchase) originated from a real
6
+ * human on real hardware that had genuinely rested between interactions.
7
+ *
8
+ * This is the layer that defeats the "1,000 phones in a warehouse" attack.
9
+ * Each token proves:
10
+ *
11
+ * 1. Real hardware DRAM refresh present, ENF grid signal detected
12
+ * 2. Genuine idle Hash-chained thermal measurements spanning ≥ 45s
13
+ * 3. Physical cooling Variance decay was smooth, not a step function
14
+ * 4. Fresh interaction 30-second TTL eliminates token brokers
15
+ * 5. Tamper-evident HMAC-SHA256 over all fraud-relevant fields
16
+ *
17
+ * Token wire format (compact: base64url JSON, ~400 bytes)
18
+ * ────────────────────────────────────────────────────────
19
+ * {
20
+ * v: 2, protocol version
21
+ * n: "hex64", nonce — 256-bit random
22
+ * iat: 1234567890123, issued-at Unix ms
23
+ * exp: 1234567920123, expires-at (iat + 30s)
24
+ * idle: {
25
+ * chain: "hex64", final hash of idle measurement chain
26
+ * s: 3, sample count (≥ 2 for a valid proof)
27
+ * dMs: 180000, idle duration ms
28
+ * therm: "hot_to_cold", thermal transition label
29
+ * mono: 0.67, cooling monotonicity (0–1)
30
+ * },
31
+ * hw: {
32
+ * dram: "dram", DRAM probe verdict
33
+ * enf: "grid_60hz", ENF probe verdict
34
+ * ent: 0.73, normalized physics entropy score (0–1)
35
+ * },
36
+ * evt: {
37
+ * t: "click", event type
38
+ * ts: 1234567890123, event Unix ms
39
+ * mot: 0.82, motor consistency (0–1)
40
+ * },
41
+ * sig: "hex64" HMAC-SHA256 over all fraud-relevant fields
42
+ * }
43
+ *
44
+ * HMAC input (pipe-delimited, all fields that matter for fraud)
45
+ * ─────────────────────────────────────────────────────────────
46
+ * `v|n|iat|exp|idle.chain|idle.dMs|hw.ent|evt.t|evt.ts`
47
+ *
48
+ * Changing any signed field invalidates the token. Unsigned fields
49
+ * (therm, mono, dram, enf) are advisory — they inform risk scoring but
50
+ * cannot be manipulated for credit fraud without breaking the HMAC.
51
+ */
52
+
53
+ import { hmac } from '@noble/hashes/hmac';
54
+ import { sha256 } from '@noble/hashes/sha256';
55
+ import { bytesToHex,
56
+ utf8ToBytes,
57
+ randomBytes } from '@noble/hashes/utils';
58
+
59
+ // ── Constants ─────────────────────────────────────────────────────────────────
60
+
61
+ const TOKEN_VERSION = 2;
62
+
63
+ /** 30-second TTL: short enough to prevent token brokers (resellers of valid
64
+ * tokens scraped from legitimate devices), long enough to survive one API
65
+ * round-trip on a slow connection. */
66
+ const TOKEN_TTL_MS = 30_000;
67
+
68
+ const NONCE_BYTES = 32; // 256-bit nonce → 64-char hex
69
+
70
+ // ── createEngagementToken ─────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Create a physics-backed engagement token.
74
+ *
75
+ * Attach the returned `compact` string to your API call:
76
+ * `fetch('/api/action', { headers: { 'X-Pulse-Token': token.compact } })`
77
+ *
78
+ * @param {object} opts
79
+ * @param {object} opts.pulseResult result object from pulse()
80
+ * @param {IdleProof|null} opts.idleProof from idleMonitor.getProof()
81
+ * @param {object} opts.interaction { type, ts, motorConsistency }
82
+ * @param {string} opts.secret shared HMAC secret (min 16 chars)
83
+ * @param {object} [opts._overrides] for deterministic testing only
84
+ * @returns {EngagementToken}
85
+ */
86
+ export function createEngagementToken(opts) {
87
+ const {
88
+ pulseResult = {},
89
+ idleProof = null,
90
+ interaction = {},
91
+ secret,
92
+ _overrides = {},
93
+ } = opts;
94
+
95
+ _assertSecret(secret);
96
+
97
+ const n = _overrides.nonce ?? bytesToHex(randomBytes(NONCE_BYTES));
98
+ const iat = _overrides.issuedAt ?? Date.now();
99
+ const exp = iat + TOKEN_TTL_MS;
100
+
101
+ // ── Extract hardware evidence from pulse result ───────────────────────────
102
+ const extended = pulseResult.extended ?? {};
103
+ const dram = extended.dram?.verdict ?? pulseResult.dram?.verdict ?? 'unavailable';
104
+ const enf = extended.enf?.verdict ?? pulseResult.enf?.verdict ?? 'unavailable';
105
+ const ent = _extractEntropyScore(pulseResult);
106
+
107
+ // ENF deviation for population-level phase coherence test
108
+ const enfDev = extended.enf?.enfDeviation
109
+ ?? pulseResult.enf?.enfDeviation
110
+ ?? null;
111
+
112
+ // ── Pack idle evidence ────────────────────────────────────────────────────
113
+ const idle = idleProof
114
+ ? {
115
+ chain: idleProof.chain,
116
+ s: idleProof.samples,
117
+ dMs: idleProof.idleDurationMs,
118
+ therm: idleProof.thermalTransition,
119
+ mono: idleProof.coolingMonotonicity,
120
+ }
121
+ : null;
122
+
123
+ // ── Pack interaction evidence ─────────────────────────────────────────────
124
+ const evt = {
125
+ t: interaction.type ?? 'unknown',
126
+ ts: interaction.ts ?? iat,
127
+ mot: +(interaction.motorConsistency ?? 0).toFixed(3),
128
+ };
129
+
130
+ // ── Sign and seal ─────────────────────────────────────────────────────────
131
+ const hw = { dram, enf, ent, ...(enfDev != null && { enfDev }) };
132
+ const unsigned = { v: TOKEN_VERSION, n, iat, exp, idle, hw, evt };
133
+ const sig = _sign(unsigned, secret);
134
+ const token = { ...unsigned, sig };
135
+
136
+ return {
137
+ token,
138
+ compact: _encode(token),
139
+ expiresAt: exp,
140
+ };
141
+ }
142
+
143
+ // ── verifyEngagementToken ─────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Verify an engagement token on the server.
147
+ *
148
+ * Call this in your API handler before crediting any engagement metric.
149
+ * Failed verification returns `{ valid: false, reason }` — never throws.
150
+ *
151
+ * @param {string|object} tokenOrCompact compact base64url string or parsed token
152
+ * @param {string} secret shared HMAC secret
153
+ * @param {object} [opts]
154
+ * @param {Function} [opts.checkNonce] async (nonce: string) => boolean
155
+ * Must atomically consume the nonce (Redis DEL returning 1,
156
+ * DB transaction with SELECT FOR UPDATE, etc.)
157
+ * @param {Function} [opts.now] override Date.now for testing: () => number
158
+ * @returns {Promise<EngagementVerifyResult>}
159
+ */
160
+ export async function verifyEngagementToken(tokenOrCompact, secret, opts = {}) {
161
+ _assertSecret(secret);
162
+
163
+ // ── Parse ─────────────────────────────────────────────────────────────────
164
+ let token;
165
+ try {
166
+ token = typeof tokenOrCompact === 'string'
167
+ ? _decode(tokenOrCompact)
168
+ : tokenOrCompact;
169
+ } catch {
170
+ return _reject('malformed_token');
171
+ }
172
+
173
+ const { v, n, iat, exp, idle, hw, evt, sig } = token ?? {};
174
+
175
+ // ── Structural integrity ──────────────────────────────────────────────────
176
+ if (v !== TOKEN_VERSION) return _reject('unsupported_version');
177
+ if (!n || !/^[0-9a-f]{64}$/i.test(n)) return _reject('invalid_nonce');
178
+ if (!Number.isFinite(iat) || !Number.isFinite(exp) || !sig) {
179
+ return _reject('missing_required_fields');
180
+ }
181
+
182
+ // ── Freshness ─────────────────────────────────────────────────────────────
183
+ const now = (opts.now ?? Date.now)();
184
+ if (now > exp) return _reject('token_expired', { expiredByMs: now - exp });
185
+ if (iat > now + 5_000) return _reject('token_from_future');
186
+
187
+ // ── Signature verification (timing-safe comparison) ───────────────────────
188
+ const expected = _sign(token, secret);
189
+ if (!_timingSafeEqual(expected, sig)) {
190
+ return _reject('invalid_signature');
191
+ }
192
+
193
+ // ── Nonce consumption (replay prevention) ─────────────────────────────────
194
+ if (typeof opts.checkNonce === 'function') {
195
+ let consumed;
196
+ try { consumed = await opts.checkNonce(n); }
197
+ catch { return _reject('nonce_check_error'); }
198
+ if (!consumed) return _reject('nonce_replayed');
199
+ }
200
+
201
+ // ── Advisory analysis (non-blocking) ─────────────────────────────────────
202
+ const idleWarnings = idle ? _checkIdlePlausibility(idle) : ['no_idle_proof'];
203
+ const riskSignals = _assessRisk(hw, idle, evt);
204
+
205
+ return {
206
+ valid: true,
207
+ token,
208
+ idleWarnings,
209
+ riskSignals,
210
+ issuedAt: iat,
211
+ expiresAt: exp,
212
+ };
213
+ }
214
+
215
+ // ── Encode / decode ───────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Encode a token object to a compact base64url string.
219
+ * @param {object} token
220
+ * @returns {string}
221
+ */
222
+ export function encodeToken(token) {
223
+ return _encode(token);
224
+ }
225
+
226
+ /**
227
+ * Decode a compact string without verifying the signature.
228
+ * Safe for logging/debugging; use verifyEngagementToken for security checks.
229
+ * @param {string} compact
230
+ * @returns {object}
231
+ */
232
+ export function decodeToken(compact) {
233
+ return _decode(compact);
234
+ }
235
+
236
+ // ── Risk assessment ───────────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Advisory risk signals: concerns that don't outright invalidate the token
240
+ * but should inform downstream risk decisions.
241
+ *
242
+ * Returned in the `riskSignals` array of a successful verify result.
243
+ * Each entry: `{ code: string, severity: 'high'|'medium'|'low' }`.
244
+ */
245
+ function _assessRisk(hw, idle, evt) {
246
+ const signals = [];
247
+
248
+ // Hardware layer
249
+ if (hw?.dram === 'virtual') signals.push({ code: 'DRAM_VIRTUAL', severity: 'high' });
250
+ if (hw?.dram === 'ambiguous') signals.push({ code: 'DRAM_AMBIGUOUS', severity: 'medium' });
251
+ if (hw?.enf === 'no_grid_signal') signals.push({ code: 'NO_ENF_GRID', severity: 'medium' });
252
+ if (hw?.ent != null && hw.ent < 0.35) {
253
+ signals.push({ code: 'LOW_ENTROPY_SCORE', severity: 'high' });
254
+ }
255
+
256
+ // Idle proof layer
257
+ if (!idle) {
258
+ signals.push({ code: 'NO_IDLE_PROOF', severity: 'medium' });
259
+ } else {
260
+ if (idle.therm === 'step_function') signals.push({ code: 'STEP_FUNCTION_THERMAL', severity: 'high' });
261
+ if (idle.therm === 'sustained_hot') signals.push({ code: 'SUSTAINED_LOAD_PATTERN', severity: 'high' });
262
+ if (idle.mono < 0.30 && idle.s >= 3) signals.push({ code: 'NON_MONOTONIC_COOLING', severity: 'medium' });
263
+ if (idle.dMs < 50_000) signals.push({ code: 'MINIMAL_IDLE_DURATION', severity: 'low' });
264
+ }
265
+
266
+ // Interaction layer
267
+ if (evt?.mot != null && evt.mot < 0.25) {
268
+ signals.push({ code: 'POOR_MOTOR_CONSISTENCY', severity: 'medium' });
269
+ }
270
+
271
+ return signals;
272
+ }
273
+
274
+ function _checkIdlePlausibility(idle) {
275
+ const w = [];
276
+ if (!idle.chain || idle.chain.length !== 64) w.push('malformed_chain_hash');
277
+ if (idle.s < 2) w.push('insufficient_chain_samples');
278
+ if (idle.therm === 'step_function') w.push('step_function_transition');
279
+ if (idle.mono < 0.30 && idle.s >= 3) w.push('non_monotonic_cooling');
280
+ return w;
281
+ }
282
+
283
+ // ── HMAC ──────────────────────────────────────────────────────────────────────
284
+
285
+ function _sign({ v, n, iat, exp, idle, hw, evt }, secret) {
286
+ // All fields an attacker might want to inflate/swap are in the signed body.
287
+ // Advisory fields (therm, mono, dram labels) are deliberately excluded —
288
+ // they're useful for risk scoring but not for access control.
289
+ const body = [
290
+ v,
291
+ n,
292
+ iat,
293
+ exp,
294
+ idle?.chain ?? 'null',
295
+ idle?.dMs ?? 'null',
296
+ hw?.ent ?? 'null',
297
+ evt?.t ?? 'null',
298
+ evt?.ts ?? 'null',
299
+ ].join('|');
300
+
301
+ const mac = hmac(sha256, utf8ToBytes(secret), utf8ToBytes(body));
302
+ return bytesToHex(mac);
303
+ }
304
+
305
+ /**
306
+ * Pure-JS timing-safe string comparison for hex strings.
307
+ * Operates on char codes — constant time for equal-length inputs.
308
+ * V8 does not optimize away XOR accumulation on Uint8 arithmetic.
309
+ */
310
+ function _timingSafeEqual(a, b) {
311
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
312
+ if (a.length !== b.length) return false;
313
+ let diff = 0;
314
+ for (let i = 0; i < a.length; i++) {
315
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
316
+ }
317
+ return diff === 0;
318
+ }
319
+
320
+ // ── Encode / decode (base64url) ───────────────────────────────────────────────
321
+
322
+ function _encode(token) {
323
+ const bytes = utf8ToBytes(JSON.stringify(token));
324
+ if (typeof Buffer !== 'undefined') {
325
+ return Buffer.from(bytes).toString('base64url');
326
+ }
327
+ // Browser: manual base64url encoding
328
+ return btoa(String.fromCharCode(...bytes))
329
+ .replace(/\+/g, '-')
330
+ .replace(/\//g, '_')
331
+ .replace(/=/g, '');
332
+ }
333
+
334
+ function _decode(compact) {
335
+ // Normalize base64url to standard base64 with padding
336
+ let b64 = compact.replace(/-/g, '+').replace(/_/g, '/');
337
+ while (b64.length % 4) b64 += '=';
338
+
339
+ let bytes;
340
+ if (typeof Buffer !== 'undefined') {
341
+ bytes = Buffer.from(b64, 'base64');
342
+ } else {
343
+ const str = atob(b64);
344
+ bytes = Uint8Array.from(str, c => c.charCodeAt(0));
345
+ }
346
+
347
+ return JSON.parse(new TextDecoder().decode(bytes));
348
+ }
349
+
350
+ // ── Misc helpers ──────────────────────────────────────────────────────────────
351
+
352
+ function _reject(reason, meta = {}) {
353
+ return { valid: false, reason, ...meta };
354
+ }
355
+
356
+ function _assertSecret(secret) {
357
+ if (!secret || typeof secret !== 'string' || secret.length < 16) {
358
+ throw new Error(
359
+ '@svrnsec/pulse: engagement token secret must be ≥ 16 characters. ' +
360
+ 'Generate one with: import { generateSecret } from "@svrnsec/pulse/challenge"'
361
+ );
362
+ }
363
+ }
364
+
365
+ function _extractEntropyScore(pulseResult) {
366
+ // Normalize jitter score (0–1) from wherever it lives in the result tree
367
+ const score =
368
+ pulseResult?.payload?.classification?.jitterScore ??
369
+ pulseResult?.classification?.jitterScore ??
370
+ pulseResult?.jitterScore ??
371
+ null;
372
+ return score != null ? +Number(score).toFixed(3) : null;
373
+ }
374
+
375
+ // ── JSDoc types ───────────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * @typedef {object} EngagementToken
379
+ * @property {object} token full parsed token object
380
+ * @property {string} compact base64url-encoded compact form (attach to API headers)
381
+ * @property {number} expiresAt Unix ms expiry timestamp
382
+ */
383
+
384
+ /**
385
+ * @typedef {object} EngagementVerifyResult
386
+ * @property {boolean} valid true if all checks passed
387
+ * @property {string} [reason] rejection reason code (when valid=false)
388
+ * @property {number} [expiredByMs] how many ms ago it expired (when reason=token_expired)
389
+ * @property {object} [token] parsed token (when valid=true)
390
+ * @property {string[]} [idleWarnings] advisory idle-proof warnings
391
+ * @property {object[]} [riskSignals] non-fatal risk indicators with severity
392
+ * @property {number} [issuedAt] Unix ms issued-at
393
+ * @property {number} [expiresAt] Unix ms expiry
394
+ */