@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,403 +1,404 @@
|
|
|
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
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
// |ac|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* @
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
// CV
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* @
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* @
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* @
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
// variance
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
: score >=
|
|
366
|
-
:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
* @
|
|
387
|
-
* @property {
|
|
388
|
-
* @property {
|
|
389
|
-
* @property {
|
|
390
|
-
* @property {
|
|
391
|
-
* @property {
|
|
392
|
-
* @property {number} [hw.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
* @
|
|
398
|
-
* @property {
|
|
399
|
-
* @property {number}
|
|
400
|
-
* @property {
|
|
401
|
-
* @property {
|
|
402
|
-
* @property {string}
|
|
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
|
+
// Both positive autocorrelation (rhythmic dispatch from farm coordinator) and negative autocorrelation (perfectly alternating patterns) are treated as suspicious signals.
|
|
136
|
+
// Use the more suspicious of the two lags
|
|
137
|
+
const signal = Math.max(Math.abs(ac1), Math.abs(ac2));
|
|
138
|
+
|
|
139
|
+
// Threshold calibration:
|
|
140
|
+
// |ac| < 0.15 → independent (real users)
|
|
141
|
+
// |ac| > 0.40 → rhythmic (farm coordinator)
|
|
142
|
+
const score = Math.min(100, Math.max(0,
|
|
143
|
+
((signal - 0.15) / (0.40 - 0.15)) * 100
|
|
144
|
+
));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
score: Math.round(score),
|
|
148
|
+
ac1: +ac1.toFixed(3),
|
|
149
|
+
ac2: +ac2.toFixed(3),
|
|
150
|
+
detail: signal > 0.40 ? 'rhythmic_dispatch_detected' : 'normal_variance',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Test 2: Entropy Dispersion ────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Real users bring diverse device histories: different ages, temperatures,
|
|
158
|
+
* load profiles. Their physics scores (entropy, jitter) span a wide range.
|
|
159
|
+
*
|
|
160
|
+
* Farm devices in one warehouse are typically the same model, same ambient
|
|
161
|
+
* temperature, running the same workload. Their scores cluster tightly.
|
|
162
|
+
*
|
|
163
|
+
* @param {ParsedEngagementToken[]} tokens
|
|
164
|
+
* @returns {{ score: number, cv: number, mean: number, detail: string }}
|
|
165
|
+
*/
|
|
166
|
+
export function testEntropyDispersion(tokens) {
|
|
167
|
+
const scores = tokens.map(t => t.hw?.ent).filter(v => v != null && Number.isFinite(v) && v > 0);
|
|
168
|
+
if (scores.length < 3) {
|
|
169
|
+
return { score: 0, cv: 0, mean: 0, detail: 'no_entropy_data' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const mean = _mean(scores);
|
|
173
|
+
if (mean < 1e-9) return { score: 50, cv: 0, mean: 0, detail: 'zero_entropy_scores' };
|
|
174
|
+
|
|
175
|
+
const stddev = Math.sqrt(_variance(scores, mean));
|
|
176
|
+
const cv = stddev / mean; // Coefficient of Variation
|
|
177
|
+
|
|
178
|
+
// Calibration:
|
|
179
|
+
// CV > 0.12 → diverse hardware (real users)
|
|
180
|
+
// CV < 0.04 → homogeneous (farm devices)
|
|
181
|
+
const score = Math.min(100, Math.max(0,
|
|
182
|
+
((0.10 - cv) / (0.10 - 0.03)) * 100
|
|
183
|
+
));
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
score: Math.round(score),
|
|
187
|
+
cv: +cv.toFixed(4),
|
|
188
|
+
mean: +mean.toFixed(3),
|
|
189
|
+
detail: cv < 0.04 ? 'homogeneous_hardware_detected' : 'normal_diversity',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Test 3: Thermal Diversity ─────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Real users encounter devices in diverse thermal states throughout the day:
|
|
197
|
+
* cold morning wake-ups, warm post-commute picks, hot post-gaming sessions.
|
|
198
|
+
* The population distribution across transition labels should be broad.
|
|
199
|
+
*
|
|
200
|
+
* Farm devices maintain constant operational throughput. Their transitions
|
|
201
|
+
* cluster at 'step_function' (scripted pause) or 'sustained_hot' (constant load).
|
|
202
|
+
*
|
|
203
|
+
* @param {ParsedEngagementToken[]} tokens
|
|
204
|
+
* @returns {{ score: number, suspiciousRatio: number, distribution: object, detail: string }}
|
|
205
|
+
*/
|
|
206
|
+
export function testThermalDiversity(tokens) {
|
|
207
|
+
const transitions = tokens.map(t => t.idle?.therm).filter(Boolean);
|
|
208
|
+
if (transitions.length < 3) {
|
|
209
|
+
return { score: 0, suspiciousRatio: 0, distribution: {}, detail: 'no_thermal_data' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const counts = {};
|
|
213
|
+
for (const t of transitions) counts[t] = (counts[t] ?? 0) + 1;
|
|
214
|
+
const total = transitions.length;
|
|
215
|
+
|
|
216
|
+
// Farm-indicator transitions
|
|
217
|
+
const suspicious = (counts.step_function ?? 0) + (counts.sustained_hot ?? 0);
|
|
218
|
+
const suspiciousRatio = suspicious / total;
|
|
219
|
+
|
|
220
|
+
// Shannon entropy of the transition label distribution
|
|
221
|
+
const probs = Object.values(counts).map(c => c / total);
|
|
222
|
+
const popEntropy = _shannonEntropy(probs);
|
|
223
|
+
const maxEntropy = Math.log2(6); // 6 possible labels
|
|
224
|
+
|
|
225
|
+
// Combined: raw suspicious ratio + low population entropy both indicate farm
|
|
226
|
+
const ratioScore = Math.min(100, suspiciousRatio * 100);
|
|
227
|
+
const entropyScore = Math.min(80, (1 - popEntropy / maxEntropy) * 80);
|
|
228
|
+
const score = Math.round(0.6 * ratioScore + 0.4 * entropyScore);
|
|
229
|
+
|
|
230
|
+
const detail = suspiciousRatio > 0.50
|
|
231
|
+
? 'majority_farm_thermal_transitions'
|
|
232
|
+
: popEntropy < maxEntropy * 0.40
|
|
233
|
+
? 'low_thermal_label_diversity'
|
|
234
|
+
: 'normal_distribution';
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
score,
|
|
238
|
+
suspiciousRatio: +suspiciousRatio.toFixed(3),
|
|
239
|
+
distribution: Object.fromEntries(
|
|
240
|
+
Object.entries(counts).map(([k, v]) => [k, +(v / total).toFixed(3)])
|
|
241
|
+
),
|
|
242
|
+
detail,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Test 4: Idle Duration Plausibility ───────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Real humans idle unpredictably: 5 minutes for a notification, an hour for
|
|
250
|
+
* lunch, 8 hours overnight. The distribution is broad and roughly log-normal.
|
|
251
|
+
*
|
|
252
|
+
* Farm scripts, constrained by throughput targets, idle for exactly the
|
|
253
|
+
* minimum duration required to pass attestation (~45–90s). The population
|
|
254
|
+
* reveals a tight cluster just above the minimum viable threshold.
|
|
255
|
+
*
|
|
256
|
+
* @param {ParsedEngagementToken[]} tokens
|
|
257
|
+
* @returns {{ score: number, clusterRatio: number, medianMs: number, detail: string }}
|
|
258
|
+
*/
|
|
259
|
+
export function testIdlePlausibility(tokens) {
|
|
260
|
+
const durations = tokens.map(t => t.idle?.dMs).filter(d => d != null && d > 0);
|
|
261
|
+
if (durations.length < 3) {
|
|
262
|
+
return { score: 0, clusterRatio: 0, medianMs: 0, detail: 'no_idle_data' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The "barely made it" window: idle just long enough to pass, then immediately resume
|
|
266
|
+
const CLUSTER_LO = 45_000; // MIN_IDLE_MS
|
|
267
|
+
const CLUSTER_HI = 100_000; // 2.2× minimum — beyond this, farms lose too much throughput
|
|
268
|
+
|
|
269
|
+
const inCluster = durations.filter(d => d >= CLUSTER_LO && d <= CLUSTER_HI).length;
|
|
270
|
+
const clusterRatio = inCluster / durations.length;
|
|
271
|
+
|
|
272
|
+
// Low coefficient of variation = durations are suspiciously uniform
|
|
273
|
+
const mean = _mean(durations);
|
|
274
|
+
const stddev = Math.sqrt(_variance(durations, mean));
|
|
275
|
+
const cv = stddev / (mean + 1);
|
|
276
|
+
|
|
277
|
+
// Scoring: high cluster ratio + low duration CV = farm fingerprint
|
|
278
|
+
const clusterScore = Math.min(70, Math.max(0, (clusterRatio - 0.40) / (0.80 - 0.40)) * 70);
|
|
279
|
+
const cvScore = Math.min(30, Math.max(0, (0.50 - cv) / (0.50 - 0.10)) * 30);
|
|
280
|
+
const score = Math.round(clusterScore + cvScore);
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
score,
|
|
284
|
+
clusterRatio: +clusterRatio.toFixed(3),
|
|
285
|
+
medianMs: _median(durations),
|
|
286
|
+
detail: clusterRatio > 0.70 ? 'idle_duration_clustered_at_minimum' : 'normal_distribution',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Test 5: ENF Phase Coherence ───────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* All devices on the same electrical circuit share the same ENF phase.
|
|
294
|
+
* A 1,000-device farm in a single warehouse shares one power feed — their
|
|
295
|
+
* ENF frequency deviations are nearly identical.
|
|
296
|
+
*
|
|
297
|
+
* Real users across a city are on separate circuits with independent phase
|
|
298
|
+
* evolution; their deviations spread naturally over a measurable range.
|
|
299
|
+
*
|
|
300
|
+
* @param {ParsedEngagementToken[]} tokens
|
|
301
|
+
* @returns {{ score: number, phaseVariance: number|null, detail: string }}
|
|
302
|
+
*/
|
|
303
|
+
export function testEnfCoherence(tokens) {
|
|
304
|
+
const deviations = tokens
|
|
305
|
+
.map(t => t.hw?.enfDev)
|
|
306
|
+
.filter(d => d != null && Number.isFinite(d));
|
|
307
|
+
|
|
308
|
+
if (deviations.length < 4) {
|
|
309
|
+
return { score: 0, phaseVariance: null, detail: 'enf_unavailable_or_insufficient' };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const mean = _mean(deviations);
|
|
313
|
+
const variance = _variance(deviations, mean);
|
|
314
|
+
|
|
315
|
+
// Calibration (Hz² variance):
|
|
316
|
+
// variance > 0.0002 → diverse circuits (real users across a city)
|
|
317
|
+
// variance < 0.00002 → same rack, same feed (co-located farm)
|
|
318
|
+
const score = Math.round(Math.min(100, Math.max(0,
|
|
319
|
+
(1 - variance / 0.0002) * 100
|
|
320
|
+
)));
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
score,
|
|
324
|
+
phaseVariance: +variance.toFixed(8),
|
|
325
|
+
detail: variance < 0.00002 ? 'co_located_devices_same_circuit' : 'normal_phase_spread',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
function _mean(arr) {
|
|
332
|
+
if (!arr.length) return 0;
|
|
333
|
+
return arr.reduce((s, v) => s + v, 0) / arr.length;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function _variance(arr, mean) {
|
|
337
|
+
if (arr.length < 2) return 0;
|
|
338
|
+
return arr.reduce((s, v) => s + (v - mean) ** 2, 0) / arr.length;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function _median(arr) {
|
|
342
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
343
|
+
const mid = Math.floor(s.length / 2);
|
|
344
|
+
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _autocorrAtLag(data, mean, variance, lag) {
|
|
348
|
+
const n = data.length;
|
|
349
|
+
if (lag >= n || variance < 1e-12) return 0;
|
|
350
|
+
let cov = 0;
|
|
351
|
+
for (let i = 0; i < n - lag; i++) {
|
|
352
|
+
cov += (data[i] - mean) * (data[i + lag] - mean);
|
|
353
|
+
}
|
|
354
|
+
return cov / ((n - lag) * variance);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function _shannonEntropy(probs) {
|
|
358
|
+
return -probs
|
|
359
|
+
.filter(p => p > 0)
|
|
360
|
+
.reduce((s, p) => s + p * Math.log2(p), 0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function _formatSummary(score, flags, n) {
|
|
364
|
+
const risk = score >= 80 ? 'HIGH_RISK'
|
|
365
|
+
: score >= 50 ? 'SUSPICIOUS'
|
|
366
|
+
: score >= 30 ? 'MARGINAL'
|
|
367
|
+
: 'CLEAN';
|
|
368
|
+
const flagStr = flags.length ? ` flags=[${flags.join(',')}]` : '';
|
|
369
|
+
return `${risk}: sybilScore=${score}/100 n=${n}${flagStr}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function _insufficientSample(n, required) {
|
|
373
|
+
return {
|
|
374
|
+
authentic: true, // default to not blocking on insufficient data
|
|
375
|
+
sybilScore: 0,
|
|
376
|
+
confidence: 0,
|
|
377
|
+
tests: {},
|
|
378
|
+
flags: [],
|
|
379
|
+
summary: `INSUFFICIENT_SAMPLE: ${n}/${required} tokens required for analysis`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── JSDoc types ───────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @typedef {object} ParsedEngagementToken
|
|
387
|
+
* @property {number} iat issued-at Unix ms
|
|
388
|
+
* @property {object} [idle] idle proof summary
|
|
389
|
+
* @property {number|null} [idle.dMs] idle duration ms
|
|
390
|
+
* @property {string} [idle.therm] thermal transition label
|
|
391
|
+
* @property {object} [hw] hardware signal summary
|
|
392
|
+
* @property {number} [hw.ent] normalized entropy score (0–1)
|
|
393
|
+
* @property {number} [hw.enfDev] ENF frequency deviation (Hz)
|
|
394
|
+
*/
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* @typedef {object} PopulationVerdict
|
|
398
|
+
* @property {boolean} authentic true if population appears legitimate (sybilScore < 40)
|
|
399
|
+
* @property {number} sybilScore 0–100 suspicion score (higher = more suspicious)
|
|
400
|
+
* @property {number} confidence 0–1 confidence in verdict (scales with cohort size)
|
|
401
|
+
* @property {object} tests per-test result objects
|
|
402
|
+
* @property {string[]} flags fired detection flags
|
|
403
|
+
* @property {string} summary one-line human-readable summary
|
|
404
|
+
*/
|