@svrnsec/pulse 0.3.1 → 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.
@@ -0,0 +1,475 @@
1
+ /**
2
+ * @sovereign/pulse — High-Level Fingerprint Class
3
+ *
4
+ * The developer-facing API. Instead of forcing devs to understand Hurst
5
+ * Exponents and Quantization Entropy, they get a Fingerprint object with
6
+ * plain-language properties and one critical boolean: isSynthetic.
7
+ *
8
+ * Usage:
9
+ *
10
+ * import { Fingerprint } from '@sovereign/pulse';
11
+ *
12
+ * const fp = await Fingerprint.collect({ nonce });
13
+ *
14
+ * if (fp.isSynthetic) {
15
+ * console.log(`Blocked: ${fp.providerLabel} detected (${fp.confidence}% confidence)`);
16
+ * console.log(`Profile: ${fp.profile}`); // 'picket-fence'
17
+ * console.log(`Reason: ${fp.topFlag}`); // 'LOW_QE + HIGH_LAG1_AUTOCORR'
18
+ * } else {
19
+ * console.log(`Verified: ${fp.hardwareId()}`);
20
+ * console.log(`Score: ${fp.score}`); // 0.0 – 1.0
21
+ * }
22
+ *
23
+ * // Always send to server for final validation:
24
+ * const { payload, hash } = fp.toCommitment();
25
+ */
26
+
27
+ import { collectEntropy } from './collector/entropy.js';
28
+ import { BioCollector } from './collector/bio.js';
29
+ import { collectCanvasFingerprint } from './collector/canvas.js';
30
+ import { collectAudioJitter } from './analysis/audio.js';
31
+ import { classifyJitter } from './analysis/jitter.js';
32
+ import { runHeuristicEngine } from './analysis/heuristic.js';
33
+ import { runCoherenceAnalysis } from './analysis/coherence.js';
34
+ import { detectProvider } from './analysis/provider.js';
35
+ import { buildProof, buildCommitment, blake3HexStr } from './proof/fingerprint.js';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Fingerprint class
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export class Fingerprint {
42
+ /** @private */
43
+ constructor(raw) {
44
+ this._raw = raw; // full internal data
45
+ this._commitment = null; // lazy-built on first toCommitment() call
46
+ }
47
+
48
+ // ── Static factory ────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Collect all hardware signals and return a Fingerprint instance.
52
+ *
53
+ * @param {object} opts
54
+ * @param {string} opts.nonce - server-issued challenge nonce (required)
55
+ * @param {number} [opts.iterations=200]
56
+ * @param {number} [opts.bioWindowMs=3000]
57
+ * @param {boolean} [opts.phased=true] - run cold/load/hot phases
58
+ * @param {Function} [opts.onProgress] - (stage: string) => void
59
+ * @param {string} [opts.wasmPath]
60
+ * @returns {Promise<Fingerprint>}
61
+ */
62
+ static async collect(opts = {}) {
63
+ const {
64
+ nonce,
65
+ iterations = 200,
66
+ bioWindowMs = 3000,
67
+ phased = true,
68
+ adaptive = true,
69
+ adaptiveThreshold = 0.85,
70
+ onProgress,
71
+ wasmPath,
72
+ } = opts;
73
+
74
+ if (!nonce) throw new Error('Fingerprint.collect() requires opts.nonce');
75
+
76
+ const emit = (stage, meta) => { try { onProgress?.(stage, meta); } catch {} };
77
+
78
+ emit('start');
79
+
80
+ // ── Parallel collection ────────────────────────────────────────────────
81
+ const bio = new BioCollector();
82
+ bio.start();
83
+
84
+ const [entropy, canvas, audio] = await Promise.all([
85
+ collectEntropy({
86
+ iterations, phased, adaptive, adaptiveThreshold, wasmPath,
87
+ onBatch: (meta) => emit('entropy_batch', meta),
88
+ }).then(r => { emit('entropy_done'); return r; }),
89
+ collectCanvasFingerprint()
90
+ .then(r => { emit('canvas_done'); return r; }),
91
+ collectAudioJitter({ durationMs: Math.min(bioWindowMs, 2000) })
92
+ .then(r => { emit('audio_done'); return r; }),
93
+ ]);
94
+
95
+ // Wait out the bio window
96
+ const elapsed = Date.now() - entropy.collectedAt;
97
+ const remain = Math.max(0, bioWindowMs - elapsed);
98
+ if (remain > 0) await new Promise(r => setTimeout(r, remain));
99
+
100
+ bio.stop();
101
+ const bioSnapshot = bio.snapshot(entropy.timings);
102
+ emit('bio_done');
103
+
104
+ // ── Analysis pipeline ─────────────────────────────────────────────────
105
+ const jitter = classifyJitter(entropy.timings, { autocorrelations: entropy.autocorrelations });
106
+ const heuristic = runHeuristicEngine({ jitter, phases: entropy.phases, autocorrelations: entropy.autocorrelations });
107
+ const provider = detectProvider({ jitter, autocorrelations: entropy.autocorrelations, canvas, phases: entropy.phases });
108
+
109
+ // ── Three-stage scoring pipeline ──────────────────────────────────────
110
+ // Stage 1: base jitter score from timing distribution analysis
111
+ const rawScore = jitter.score;
112
+ // Stage 2: heuristic cross-metric coherence adjustment
113
+ const adjScore = Math.max(0, Math.min(1, rawScore + heuristic.netAdjustment));
114
+ // Stage 3: zero-latency structural coherence analysis on already-collected data
115
+ const coherence = runCoherenceAnalysis({
116
+ timings: entropy.timings,
117
+ jitter,
118
+ phases: entropy.phases ?? null,
119
+ batches: entropy.batches ?? null,
120
+ bio: bioSnapshot,
121
+ canvas,
122
+ audio,
123
+ });
124
+ // Final score: stage-2 adjusted score refined by stage-3 coherence
125
+ const finalScore = Math.max(0, Math.min(1, adjScore + coherence.netAdjustment));
126
+
127
+ emit('analysis_done');
128
+
129
+ // ── Build commitment ──────────────────────────────────────────────────
130
+ const payload = buildProof({ entropy, jitter, bio: bioSnapshot, canvas, audio, nonce });
131
+ // Inject heuristic + provider into proof payload for server-side reference
132
+ payload.heuristic = {
133
+ penalty: heuristic.penalty,
134
+ bonus: heuristic.bonus,
135
+ entropyJitterRatio: heuristic.entropyJitterRatio,
136
+ picketFence: heuristic.picketFence.detected,
137
+ coherenceFlags: heuristic.coherenceFlags,
138
+ hardOverride: heuristic.hardOverride, // 'vm' | null
139
+ };
140
+ payload.provider = {
141
+ id: provider.providerId,
142
+ label: provider.providerLabel,
143
+ profile: provider.profile,
144
+ confidence: provider.confidence,
145
+ schedulerQuantum: provider.schedulerQuantumMs,
146
+ };
147
+ // Stage-3 coherence summary (server uses these for logging + dynamic threshold)
148
+ payload.coherence = {
149
+ netAdjustment: coherence.netAdjustment,
150
+ dynamicThreshold: coherence.dynamicThreshold,
151
+ evidenceWeight: coherence.evidenceWeight,
152
+ coherenceFlags: coherence.coherenceFlags,
153
+ physicalFlags: coherence.physicalFlags,
154
+ hardOverride: coherence.hardOverride,
155
+ };
156
+ payload.classification.adjustedScore = _round(adjScore, 4);
157
+ payload.classification.finalScore = _round(finalScore, 4);
158
+ payload.classification.dynamicThreshold = coherence.dynamicThreshold;
159
+
160
+ const commitment = buildCommitment(payload);
161
+ emit('complete');
162
+
163
+ return new Fingerprint({
164
+ entropy, canvas, audio,
165
+ bioSnapshot, jitter, heuristic, coherence, provider,
166
+ rawScore, adjScore, finalScore,
167
+ nonce, commitment,
168
+ });
169
+ }
170
+
171
+ // ── Primary API ────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * True if the device is likely a VM, AI inference endpoint, or sanitised
175
+ * cloud environment. Uses the adjusted score (base + heuristic bonuses/penalties).
176
+ * @type {boolean}
177
+ */
178
+ get isSynthetic() {
179
+ // Stage-2 hard kill: EJR/QE mathematical contradiction detected in the
180
+ // heuristic engine before any bonuses could accumulate.
181
+ if (this._raw.heuristic.hardOverride === 'vm') return true;
182
+ // Stage-3 hard kill: EJR/QE contradiction or phase forgery detected in
183
+ // the coherence analyser (second line of defence).
184
+ if (this._raw.coherence.hardOverride === 'vm') return true;
185
+ // Normal path: final score vs dynamic threshold.
186
+ return this._raw.finalScore < this._raw.coherence.dynamicThreshold;
187
+ }
188
+
189
+ /**
190
+ * Confidence in the isSynthetic verdict, 0–100.
191
+ * @type {number}
192
+ */
193
+ get confidence() {
194
+ const s = this._raw.finalScore;
195
+ const t = this._raw.coherence.dynamicThreshold;
196
+ // Map distance from threshold to confidence percentage.
197
+ // At the threshold: 0% confident. Far above/below: approaching 100%.
198
+ const distance = Math.abs(s - t);
199
+ return Math.min(100, Math.round(distance * 500));
200
+ }
201
+
202
+ /**
203
+ * Normalised score [0.0, 1.0]. Higher = more physical.
204
+ * This is the FINAL score after all three analysis stages.
205
+ * @type {number}
206
+ */
207
+ get score() {
208
+ return _round(this._raw.finalScore, 4);
209
+ }
210
+
211
+ /**
212
+ * The dynamic passing threshold for this proof [0.55, 0.67].
213
+ * Reflects how much evidence was collected — a full-evidence proof has a
214
+ * lower (more permissive) threshold; a minimal-evidence proof has a higher
215
+ * (more conservative) threshold.
216
+ * @type {number}
217
+ */
218
+ get threshold() {
219
+ return this._raw.coherence.dynamicThreshold;
220
+ }
221
+
222
+ /**
223
+ * How much evidence was collected [0, 1].
224
+ * 1.0 = 200 iterations + phased + bio + audio + canvas
225
+ * 0.0 = minimal proof
226
+ * @type {number}
227
+ */
228
+ get evidenceWeight() {
229
+ return this._raw.coherence.evidenceWeight;
230
+ }
231
+
232
+ /**
233
+ * Human-readable confidence tier.
234
+ * @type {'high'|'medium'|'low'|'uncertain'}
235
+ */
236
+ get tier() {
237
+ const c = this.confidence;
238
+ if (c >= 70) return 'high';
239
+ if (c >= 40) return 'medium';
240
+ if (c >= 20) return 'low';
241
+ return 'uncertain';
242
+ }
243
+
244
+ /**
245
+ * Detected timing profile name.
246
+ * 'analog-fog' → real hardware, natural Brownian noise
247
+ * 'picket-fence' → VM steal-time bursts at regular intervals
248
+ * 'burst-scheduler' → irregular VM scheduling (VMware-style)
249
+ * 'hypervisor-flat' → flat timing, hypervisor completely irons out noise
250
+ * 'near-physical' → hard to classify (Nitro, GPU passthrough)
251
+ * 'unknown'
252
+ * @type {string}
253
+ */
254
+ get profile() {
255
+ return this._raw.provider.profile;
256
+ }
257
+
258
+ /**
259
+ * Detected cloud provider / hypervisor.
260
+ * @type {string} e.g. 'kvm-digitalocean', 'nitro-aws', 'physical', 'generic-vm'
261
+ */
262
+ get providerId() {
263
+ return this._raw.provider.providerId;
264
+ }
265
+
266
+ /**
267
+ * Human-readable provider label.
268
+ * @type {string} e.g. 'DigitalOcean Droplet (KVM)', 'Physical Hardware'
269
+ */
270
+ get providerLabel() {
271
+ return this._raw.provider.providerLabel;
272
+ }
273
+
274
+ /**
275
+ * Estimated hypervisor scheduler quantum in milliseconds.
276
+ * Null if the device appears to be physical.
277
+ * @type {number|null}
278
+ */
279
+ get schedulerQuantumMs() {
280
+ return this._raw.provider.schedulerQuantumMs;
281
+ }
282
+
283
+ /**
284
+ * Entropy-Jitter Ratio — the key signal distinguishing real silicon from VMs.
285
+ * Values ≥ 1.08 confirm thermal feedback (real hardware).
286
+ * Values near 1.0 indicate a hypervisor clock (VM).
287
+ * Null if phased collection was not run.
288
+ * @type {number|null}
289
+ */
290
+ get entropyJitterRatio() {
291
+ return this._raw.heuristic.entropyJitterRatio;
292
+ }
293
+
294
+ /**
295
+ * The most diagnostic flag from the heuristic engine.
296
+ * @type {string}
297
+ */
298
+ get topFlag() {
299
+ const flags = [
300
+ ...this._raw.heuristic.findings.map(f => f.id),
301
+ ...this._raw.jitter.flags,
302
+ ];
303
+ return flags[0] ?? 'NONE';
304
+ }
305
+
306
+ /**
307
+ * All flags from both the base classifier and heuristic engine.
308
+ * @type {string[]}
309
+ */
310
+ get flags() {
311
+ return [
312
+ ...this._raw.heuristic.coherenceFlags,
313
+ ...this._raw.jitter.flags,
314
+ ];
315
+ }
316
+
317
+ /**
318
+ * Summary of heuristic findings with human-readable labels.
319
+ * @type {Array<{id, label, severity, detail}>}
320
+ */
321
+ get findings() {
322
+ return this._raw.heuristic.findings;
323
+ }
324
+
325
+ /**
326
+ * Confirmed physical properties (positive evidence for real hardware).
327
+ * @type {Array<{id, label, detail}>}
328
+ */
329
+ get physicalEvidence() {
330
+ return this._raw.heuristic.bonuses;
331
+ }
332
+
333
+ // ── Hardware ID ────────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * A stable, privacy-preserving hardware identifier derived from the GPU
337
+ * canvas fingerprint, audio sample rate, and WebGL extension set.
338
+ *
339
+ * Properties:
340
+ * - Stable: same device → same ID across sessions
341
+ * - Not uniquely identifying: changes if GPU or driver changes
342
+ * - Not reversible: BLAKE3 hash, cannot recover original signals
343
+ * - Not a tracking cookie: no PII, no cross-origin data
344
+ *
345
+ * @returns {string} 16-character hex ID
346
+ */
347
+ hardwareId() {
348
+ const { canvas, audio } = this._raw;
349
+ const components = [
350
+ canvas?.webglRenderer ?? '',
351
+ canvas?.webglVendor ?? '',
352
+ canvas?.extensionCount?.toString() ?? '',
353
+ audio?.sampleRate?.toString() ?? '',
354
+ canvas?.webglVersion?.toString() ?? '',
355
+ ].join('|');
356
+ return blake3HexStr(components).slice(0, 16);
357
+ }
358
+
359
+ // ── Diagnostic data ────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * Key metrics summary — useful for logging and debugging.
363
+ * @returns {object}
364
+ */
365
+ metrics() {
366
+ const { jitter, heuristic, coherence, provider } = this._raw;
367
+ return {
368
+ // ── Final verdict ──────────────────────────────────────────────────
369
+ score: this.score, // final (stage 3)
370
+ threshold: this.threshold, // dynamic passing bar
371
+ evidenceWeight: this.evidenceWeight,
372
+ isSynthetic: this.isSynthetic,
373
+ // ── Score pipeline breakdown ───────────────────────────────────────
374
+ rawScore: _round(this._raw.rawScore, 4), // stage 1
375
+ adjustedScore: _round(this._raw.adjScore, 4), // stage 2
376
+ finalScore: _round(this._raw.finalScore, 4), // stage 3
377
+ heuristicAdjustment: _round(heuristic.netAdjustment, 4),
378
+ coherenceAdjustment: _round(coherence.netAdjustment, 4),
379
+ // ── Timing signals ─────────────────────────────────────────────────
380
+ cv: _round(jitter.stats?.cv, 4),
381
+ hurstExponent: _round(jitter.hurstExponent, 4),
382
+ quantizationEntropy: _round(jitter.quantizationEntropy, 4),
383
+ autocorrLag1: _round(jitter.autocorrelations?.lag1, 4),
384
+ autocorrLag50: _round(this._raw.entropy.autocorrelations?.lag50, 4),
385
+ outlierRate: _round(jitter.outlierRate, 4),
386
+ thermalPattern: jitter.thermalSignature?.pattern,
387
+ entropyJitterRatio: _round(heuristic.entropyJitterRatio, 4),
388
+ picketFence: heuristic.picketFence.detected,
389
+ // ── Coherence signals ──────────────────────────────────────────────
390
+ coherenceFlags: coherence.coherenceFlags,
391
+ physicalFlags: coherence.physicalFlags,
392
+ hardOverride: coherence.hardOverride,
393
+ // ── Provider ───────────────────────────────────────────────────────
394
+ provider: provider.providerLabel,
395
+ providerConfidence: provider.confidence,
396
+ schedulerQuantumMs: provider.schedulerQuantumMs,
397
+ // ── Hardware ───────────────────────────────────────────────────────
398
+ webglRenderer: this._raw.canvas?.webglRenderer,
399
+ isSoftwareRenderer: this._raw.canvas?.isSoftwareRenderer,
400
+ hardwareId: this.hardwareId(),
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Full diagnostic report for debugging / integration testing.
406
+ * @returns {object}
407
+ */
408
+ report() {
409
+ const { coherence } = this._raw;
410
+ return {
411
+ verdict: {
412
+ isSynthetic: this.isSynthetic,
413
+ score: this.score,
414
+ threshold: this.threshold,
415
+ confidence: this.confidence,
416
+ tier: this.tier,
417
+ profile: this.profile,
418
+ provider: this.providerLabel,
419
+ topFlag: this.topFlag,
420
+ hardOverride: coherence.hardOverride,
421
+ evidenceWeight: this.evidenceWeight,
422
+ },
423
+ pipeline: {
424
+ rawScore: _round(this._raw.rawScore, 4),
425
+ adjustedScore: _round(this._raw.adjScore, 4),
426
+ finalScore: _round(this._raw.finalScore, 4),
427
+ heuristicAdjustment: _round(this._raw.heuristic.netAdjustment, 4),
428
+ coherenceAdjustment: _round(coherence.netAdjustment, 4),
429
+ dynamicThreshold: coherence.dynamicThreshold,
430
+ },
431
+ metrics: this.metrics(),
432
+ findings: this.findings,
433
+ physicalEvidence: this.physicalEvidence,
434
+ coherenceChecks: coherence.checks,
435
+ coherenceBonuses: coherence.bonuses,
436
+ phases: this._raw.entropy.phases ? {
437
+ cold: { qe: _round(this._raw.entropy.phases.cold.qe, 4), mean: _round(this._raw.entropy.phases.cold.mean, 4) },
438
+ hot: { qe: _round(this._raw.entropy.phases.hot.qe, 4), mean: _round(this._raw.entropy.phases.hot.mean, 4) },
439
+ entropyJitterRatio: _round(this._raw.entropy.phases.entropyJitterRatio, 4),
440
+ } : null,
441
+ };
442
+ }
443
+
444
+ // ── Proof commitment ───────────────────────────────────────────────────────
445
+
446
+ /**
447
+ * Returns the BLAKE3 commitment to send to the server for validation.
448
+ * @returns {{ payload: object, hash: string }}
449
+ */
450
+ toCommitment() {
451
+ return this._raw.commitment;
452
+ }
453
+
454
+ // ── String representations ─────────────────────────────────────────────────
455
+
456
+ toString() {
457
+ const icon = this.isSynthetic ? '🚩' : '✅';
458
+ const verb = this.isSynthetic ? 'Synthetic' : 'Physical';
459
+ return `${icon} ${verb} | ${this.providerLabel} | score=${this.score} | conf=${this.confidence}% | profile=${this.profile}`;
460
+ }
461
+
462
+ toJSON() {
463
+ return this.report();
464
+ }
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Internal helpers
469
+ // ---------------------------------------------------------------------------
470
+
471
+ function _round(v, d) {
472
+ if (v == null || !isFinite(v)) return null;
473
+ const f = 10 ** d;
474
+ return Math.round(v * f) / f;
475
+ }