@svrnsec/pulse 0.5.0 → 0.7.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/README.md +242 -82
- package/SECURITY.md +1 -1
- package/dist/pulse.cjs.js +25 -25
- package/dist/pulse.cjs.js.map +1 -1
- package/dist/pulse.esm.js +25 -25
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +3 -3
- package/package.json +29 -3
- package/src/analysis/audio.js +1 -1
- package/src/analysis/authenticityAudit.js +393 -0
- package/src/analysis/coherence.js +1 -1
- package/src/analysis/coordinatedBehavior.js +804 -0
- package/src/analysis/heuristic.js +1 -1
- package/src/analysis/jitter.js +1 -1
- package/src/analysis/llm.js +1 -1
- package/src/analysis/provider.js +1 -1
- package/src/analysis/refraction.js +391 -0
- package/src/collector/adaptive.js +1 -1
- package/src/collector/bio.js +1 -1
- package/src/collector/canvas.js +1 -1
- package/src/collector/dram.js +1 -1
- package/src/collector/enf.js +1 -1
- package/src/collector/entropy.js +1 -1
- package/src/collector/gpu.js +1 -1
- package/src/collector/sabTimer.js +2 -2
- package/src/fingerprint.js +2 -2
- package/src/index.js +6 -6
- package/src/integrations/react.js +2 -2
- package/src/middleware/express.js +2 -2
- package/src/middleware/next.js +3 -3
- package/src/proof/fingerprint.js +1 -1
- package/src/proof/validator.js +1 -1
- package/src/registry/serializer.js +4 -4
package/src/analysis/jitter.js
CHANGED
package/src/analysis/llm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse — LLM / AI Agent Behavioral Fingerprint
|
|
3
3
|
*
|
|
4
4
|
* Detects automation driven by large language models, headless browsers
|
|
5
5
|
* controlled by AI agents (AutoGPT, CrewAI, browser-use, Playwright+LLM,
|
package/src/analysis/provider.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse — Hypervisor & Cloud Provider Fingerprinter
|
|
3
3
|
*
|
|
4
4
|
* Each hypervisor has a distinct "steal-time rhythm" — a characteristic
|
|
5
5
|
* pattern in how it schedules guest vCPUs on host physical cores.
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — Refraction
|
|
3
|
+
*
|
|
4
|
+
* The same physics signal "refracts" through different measurement mediums.
|
|
5
|
+
* A browser's clamped performance.now() (~100μs Spectre mitigation) shifts
|
|
6
|
+
* every statistical distribution compared to Node.js process.hrtime.bigint().
|
|
7
|
+
*
|
|
8
|
+
* Instead of maintaining two sets of hardcoded thresholds, Refraction:
|
|
9
|
+
* 1. Probes the timer to measure actual resolution
|
|
10
|
+
* 2. Detects the execution environment (browser, Node, Deno, worker, etc.)
|
|
11
|
+
* 3. Computes a calibration profile that shifts all scoring bands
|
|
12
|
+
* 4. Exposes the profile so every downstream analyzer (jitter, trustScore,
|
|
13
|
+
* population entropy) can score against the correct baseline
|
|
14
|
+
*
|
|
15
|
+
* Core insight:
|
|
16
|
+
* A VM in Node.js and real hardware in a browser can produce identical
|
|
17
|
+
* Hurst exponents. Without knowing the medium, the score is meaningless.
|
|
18
|
+
* Refraction makes the medium explicit.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* import { calibrate, getProfile } from '@svrnsec/pulse/refraction'
|
|
22
|
+
* const profile = await calibrate() // run once at init
|
|
23
|
+
* const score = classifyJitter(timings, { refraction: profile })
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ─── Environment detection ───────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const ENV = Object.freeze({
|
|
29
|
+
NODE: 'node',
|
|
30
|
+
BROWSER: 'browser',
|
|
31
|
+
WORKER: 'worker', // Web Worker / Service Worker
|
|
32
|
+
DENO: 'deno',
|
|
33
|
+
BUN: 'bun',
|
|
34
|
+
UNKNOWN: 'unknown',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect the current JS runtime without relying on user-agent sniffing.
|
|
39
|
+
* Uses capability detection — what APIs exist, not what strings say.
|
|
40
|
+
*/
|
|
41
|
+
function detectEnvironment() {
|
|
42
|
+
// Deno has Deno global
|
|
43
|
+
if (typeof globalThis.Deno !== 'undefined') return ENV.DENO;
|
|
44
|
+
// Bun has Bun global
|
|
45
|
+
if (typeof globalThis.Bun !== 'undefined') return ENV.BUN;
|
|
46
|
+
// Node.js has process.versions.node
|
|
47
|
+
if (typeof globalThis.process !== 'undefined' &&
|
|
48
|
+
globalThis.process.versions?.node) return ENV.NODE;
|
|
49
|
+
// Web Worker has self but no document
|
|
50
|
+
if (typeof globalThis.WorkerGlobalScope !== 'undefined') return ENV.WORKER;
|
|
51
|
+
// Browser has window + document
|
|
52
|
+
if (typeof globalThis.window !== 'undefined' &&
|
|
53
|
+
typeof globalThis.document !== 'undefined') return ENV.BROWSER;
|
|
54
|
+
return ENV.UNKNOWN;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Timer resolution probe ──────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Measure the actual timer resolution by collecting minimum non-zero deltas.
|
|
61
|
+
* Returns resolution in microseconds.
|
|
62
|
+
*
|
|
63
|
+
* Browser (Spectre-mitigated): typically 100μs
|
|
64
|
+
* Node.js hrtime: typically < 1μs
|
|
65
|
+
* Node.js performance.now(): typically ~1μs
|
|
66
|
+
*
|
|
67
|
+
* We probe with performance.now() since it's cross-platform.
|
|
68
|
+
* In Node.js we also check process.hrtime.bigint() availability.
|
|
69
|
+
*/
|
|
70
|
+
function probeTimerResolution(iterations = 500) {
|
|
71
|
+
const deltas = [];
|
|
72
|
+
const pnow = typeof performance !== 'undefined' && performance.now
|
|
73
|
+
? () => performance.now()
|
|
74
|
+
: () => Date.now(); // fallback — 1ms resolution
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < iterations; i++) {
|
|
77
|
+
const t0 = pnow();
|
|
78
|
+
// Minimal work — just enough to not get optimized away
|
|
79
|
+
let x = 0;
|
|
80
|
+
for (let j = 0; j < 10; j++) x += j;
|
|
81
|
+
const t1 = pnow();
|
|
82
|
+
const dt = t1 - t0;
|
|
83
|
+
if (dt > 0) deltas.push(dt);
|
|
84
|
+
if (x === -1) deltas.push(0); // prevent dead code elimination
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (deltas.length === 0) return { resolutionUs: 1000, grain: 'coarse' };
|
|
88
|
+
|
|
89
|
+
deltas.sort((a, b) => a - b);
|
|
90
|
+
|
|
91
|
+
// Minimum non-zero delta = timer resolution floor
|
|
92
|
+
const minDelta = deltas[0];
|
|
93
|
+
// Median gives stable estimate
|
|
94
|
+
const medDelta = deltas[Math.floor(deltas.length / 2)];
|
|
95
|
+
// Count unique values — clamped timers produce few unique values
|
|
96
|
+
const unique = new Set(deltas.map(d => d.toFixed(4))).size;
|
|
97
|
+
const uniqueRatio = unique / deltas.length;
|
|
98
|
+
|
|
99
|
+
const resolutionUs = minDelta * 1000; // ms → μs
|
|
100
|
+
|
|
101
|
+
let grain;
|
|
102
|
+
if (resolutionUs < 5) grain = 'nanosecond'; // < 5μs — Node.js / Deno
|
|
103
|
+
else if (resolutionUs < 50) grain = 'fine'; // 5–50μs — some browsers with relaxed policy
|
|
104
|
+
else if (resolutionUs < 200) grain = 'clamped'; // 50–200μs — standard Spectre mitigation
|
|
105
|
+
else grain = 'coarse'; // > 200μs — aggressive clamping or Date.now()
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
resolutionUs: +resolutionUs.toFixed(2),
|
|
109
|
+
minDeltaMs: +minDelta.toFixed(6),
|
|
110
|
+
medDeltaMs: +medDelta.toFixed(6),
|
|
111
|
+
uniqueRatio: +uniqueRatio.toFixed(4),
|
|
112
|
+
grain,
|
|
113
|
+
samples: deltas.length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if high-resolution timer is available (Node.js process.hrtime).
|
|
119
|
+
*/
|
|
120
|
+
function hasHrtime() {
|
|
121
|
+
return typeof globalThis.process !== 'undefined' &&
|
|
122
|
+
typeof globalThis.process.hrtime?.bigint === 'function';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Threshold profile ───────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scoring thresholds shift based on timer grain.
|
|
129
|
+
*
|
|
130
|
+
* Why each threshold changes:
|
|
131
|
+
*
|
|
132
|
+
* CV (Coefficient of Variation):
|
|
133
|
+
* Clamped timers absorb small jitter → CV appears lower on real hardware.
|
|
134
|
+
* But heavy ops (32K+) push deltas above clamp floor, creating burst variance.
|
|
135
|
+
* Browser real HW: CV 0.01–0.90. Node real HW: CV 0.04–0.35.
|
|
136
|
+
*
|
|
137
|
+
* Hurst Exponent:
|
|
138
|
+
* Timer clamping introduces quantization steps that shift H upward.
|
|
139
|
+
* Browser real HW: H 0.15–0.82. Node real HW: H 0.25–0.55.
|
|
140
|
+
*
|
|
141
|
+
* Autocorrelation:
|
|
142
|
+
* Browser event loop scheduling adds baseline AC ~0.3–0.5 on real hardware.
|
|
143
|
+
* VMs in browser still show higher AC > 0.65 from hypervisor tick.
|
|
144
|
+
* Node real HW: AC < 0.20. Browser real HW: AC < 0.50.
|
|
145
|
+
*
|
|
146
|
+
* Quantization Entropy:
|
|
147
|
+
* Fewer unique timer values → fewer populated bins → lower QE.
|
|
148
|
+
* Node real HW: QE > 3.0. Browser real HW: QE > 0.8.
|
|
149
|
+
*
|
|
150
|
+
* Unique Value Ratio:
|
|
151
|
+
* Clamped timers repeat values more.
|
|
152
|
+
* Node real HW: UVR > 0.60. Browser real HW: UVR > 0.15.
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
const PROFILES = {
|
|
156
|
+
nanosecond: {
|
|
157
|
+
label: 'High-resolution timer (Node.js / Deno)',
|
|
158
|
+
cv: { floor: 0.04, ceil: 0.35, vmFloor: 0.02 },
|
|
159
|
+
hurst: { floor: 0.25, ceil: 0.55, vmCeil: 0.60 },
|
|
160
|
+
ac: { pass: 0.20, warn: 0.35, fail: 0.50 },
|
|
161
|
+
qe: { pass: 3.0, warn: 1.5 },
|
|
162
|
+
uvr: { pass: 0.60, warn: 0.30 },
|
|
163
|
+
dram: { elFloor: 2, mcvFloor: 0.04 },
|
|
164
|
+
},
|
|
165
|
+
fine: {
|
|
166
|
+
label: 'Fine timer (relaxed browser policy)',
|
|
167
|
+
cv: { floor: 0.02, ceil: 0.60, vmFloor: 0.01 },
|
|
168
|
+
hurst: { floor: 0.20, ceil: 0.70, vmCeil: 0.75 },
|
|
169
|
+
ac: { pass: 0.35, warn: 0.50, fail: 0.60 },
|
|
170
|
+
qe: { pass: 1.5, warn: 0.8 },
|
|
171
|
+
uvr: { pass: 0.30, warn: 0.15 },
|
|
172
|
+
dram: { elFloor: 3, mcvFloor: 0.03 },
|
|
173
|
+
},
|
|
174
|
+
clamped: {
|
|
175
|
+
label: 'Spectre-mitigated browser timer (~100μs)',
|
|
176
|
+
cv: { floor: 0.01, ceil: 0.90, vmFloor: 0.005 },
|
|
177
|
+
hurst: { floor: 0.15, ceil: 0.82, vmCeil: 0.88 },
|
|
178
|
+
ac: { pass: 0.50, warn: 0.60, fail: 0.70 },
|
|
179
|
+
qe: { pass: 0.8, warn: 0.5 },
|
|
180
|
+
uvr: { pass: 0.15, warn: 0.08 },
|
|
181
|
+
dram: { elFloor: 5, mcvFloor: 0.02 },
|
|
182
|
+
},
|
|
183
|
+
coarse: {
|
|
184
|
+
label: 'Coarse timer (aggressive clamping / Date.now)',
|
|
185
|
+
cv: { floor: 0.005, ceil: 1.0, vmFloor: 0.002 },
|
|
186
|
+
hurst: { floor: 0.10, ceil: 0.88, vmCeil: 0.92 },
|
|
187
|
+
ac: { pass: 0.55, warn: 0.65, fail: 0.75 },
|
|
188
|
+
qe: { pass: 0.5, warn: 0.3 },
|
|
189
|
+
uvr: { pass: 0.08, warn: 0.04 },
|
|
190
|
+
dram: { elFloor: 5, mcvFloor: 0.02 },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ─── Calibration ─────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/** @type {RefractionProfile|null} */
|
|
197
|
+
let _cached = null;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Run the full calibration sequence. Call once at init; results are cached.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} [opts]
|
|
203
|
+
* @param {boolean} [opts.force=false] - bypass cache and re-probe
|
|
204
|
+
* @returns {Promise<RefractionProfile>}
|
|
205
|
+
*/
|
|
206
|
+
export async function calibrate(opts = {}) {
|
|
207
|
+
if (_cached && !opts.force) return _cached;
|
|
208
|
+
|
|
209
|
+
const env = detectEnvironment();
|
|
210
|
+
const timer = probeTimerResolution();
|
|
211
|
+
const hrtime = hasHrtime();
|
|
212
|
+
|
|
213
|
+
// If Node.js with hrtime, always use nanosecond profile regardless of
|
|
214
|
+
// performance.now() resolution (which may be coarser on some builds).
|
|
215
|
+
const grain = (env === ENV.NODE && hrtime) ? 'nanosecond' : timer.grain;
|
|
216
|
+
const thresholds = PROFILES[grain];
|
|
217
|
+
|
|
218
|
+
_cached = Object.freeze({
|
|
219
|
+
env,
|
|
220
|
+
timer,
|
|
221
|
+
grain,
|
|
222
|
+
hrtime,
|
|
223
|
+
thresholds,
|
|
224
|
+
label: thresholds.label,
|
|
225
|
+
calibratedAt: Date.now(),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return _cached;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get the cached profile. Returns null if calibrate() hasn't been called.
|
|
233
|
+
* @returns {RefractionProfile|null}
|
|
234
|
+
*/
|
|
235
|
+
export function getProfile() {
|
|
236
|
+
return _cached;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Synchronous calibration for environments that can't await.
|
|
241
|
+
* Slightly less accurate than async version but sufficient for most cases.
|
|
242
|
+
*/
|
|
243
|
+
export function calibrateSync() {
|
|
244
|
+
if (_cached) return _cached;
|
|
245
|
+
|
|
246
|
+
const env = detectEnvironment();
|
|
247
|
+
const timer = probeTimerResolution();
|
|
248
|
+
const hrtime = hasHrtime();
|
|
249
|
+
const grain = (env === ENV.NODE && hrtime) ? 'nanosecond' : timer.grain;
|
|
250
|
+
const thresholds = PROFILES[grain];
|
|
251
|
+
|
|
252
|
+
_cached = Object.freeze({
|
|
253
|
+
env,
|
|
254
|
+
timer,
|
|
255
|
+
grain,
|
|
256
|
+
hrtime,
|
|
257
|
+
thresholds,
|
|
258
|
+
label: thresholds.label,
|
|
259
|
+
calibratedAt: Date.now(),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return _cached;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reset cached profile. Useful for testing.
|
|
267
|
+
*/
|
|
268
|
+
export function resetProfile() {
|
|
269
|
+
_cached = null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Threshold accessors ─────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get the active thresholds for a specific signal.
|
|
276
|
+
* Falls back to 'clamped' profile if not calibrated (safe default).
|
|
277
|
+
*
|
|
278
|
+
* @param {'cv'|'hurst'|'ac'|'qe'|'uvr'|'dram'} signal
|
|
279
|
+
* @returns {object}
|
|
280
|
+
*/
|
|
281
|
+
export function getThresholds(signal) {
|
|
282
|
+
const profile = _cached?.thresholds ?? PROFILES.clamped;
|
|
283
|
+
return profile[signal];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Score a value against refraction-aware thresholds.
|
|
288
|
+
* Returns { score: 0-1, pass: boolean, flag: string|null }
|
|
289
|
+
*
|
|
290
|
+
* @param {'cv'|'hurst'|'ac'|'qe'|'uvr'} signal
|
|
291
|
+
* @param {number} value
|
|
292
|
+
* @returns {{ score: number, pass: boolean, flag: string|null }}
|
|
293
|
+
*/
|
|
294
|
+
export function scoreSignal(signal, value) {
|
|
295
|
+
const t = getThresholds(signal);
|
|
296
|
+
|
|
297
|
+
switch (signal) {
|
|
298
|
+
case 'cv': {
|
|
299
|
+
if (value >= t.floor && value <= t.ceil) return { score: 1, pass: true, flag: null };
|
|
300
|
+
if (value < t.vmFloor) return { score: 0.3, pass: false, flag: 'CV_FLAT_HYPERVISOR' };
|
|
301
|
+
if (value < t.floor) return { score: 0.6 + (value - t.vmFloor) / (t.floor - t.vmFloor) * 0.4, pass: false, flag: 'CV_LOW_BORDERLINE' };
|
|
302
|
+
if (value > t.ceil) return { score: 0.6, pass: false, flag: 'CV_HIGH_BURST' };
|
|
303
|
+
return { score: 0.5, pass: false, flag: 'CV_ANOMALOUS' };
|
|
304
|
+
}
|
|
305
|
+
case 'hurst': {
|
|
306
|
+
if (value >= t.floor && value <= t.ceil) return { score: 1, pass: true, flag: null };
|
|
307
|
+
if (value > t.vmCeil) return { score: 0.3, pass: false, flag: 'HURST_PERSISTENT_VM' };
|
|
308
|
+
if (value > t.ceil) return { score: 0.6, pass: false, flag: 'HURST_HIGH_BORDERLINE' };
|
|
309
|
+
if (value < t.floor) return { score: 0.65, pass: false, flag: 'HURST_WEAK' };
|
|
310
|
+
return { score: 0.5, pass: false, flag: 'HURST_ANOMALOUS' };
|
|
311
|
+
}
|
|
312
|
+
case 'ac': {
|
|
313
|
+
if (value < t.pass) return { score: 1, pass: true, flag: null };
|
|
314
|
+
if (value < t.warn) return { score: 0.7, pass: false, flag: 'AC_MODERATE' };
|
|
315
|
+
if (value < t.fail) return { score: 0.5, pass: false, flag: 'AC_HIGH' };
|
|
316
|
+
return { score: 0.2, pass: false, flag: 'AC_VM_PERIODIC' };
|
|
317
|
+
}
|
|
318
|
+
case 'qe': {
|
|
319
|
+
if (value >= t.pass) return { score: 1, pass: true, flag: null };
|
|
320
|
+
if (value >= t.warn) return { score: 0.75, pass: false, flag: 'QE_LOW_BORDERLINE' };
|
|
321
|
+
return { score: 0.35, pass: false, flag: 'QE_QUANTIZED' };
|
|
322
|
+
}
|
|
323
|
+
case 'uvr': {
|
|
324
|
+
if (value >= t.pass) return { score: 1, pass: true, flag: null };
|
|
325
|
+
if (value >= t.warn) return { score: 0.7, pass: false, flag: 'UVR_LOW_DIVERSITY' };
|
|
326
|
+
return { score: 0.3, pass: false, flag: 'UVR_CLAMPED' };
|
|
327
|
+
}
|
|
328
|
+
default:
|
|
329
|
+
return { score: 0.5, pass: false, flag: 'UNKNOWN_SIGNAL' };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Composite scoring ───────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Score an entire jitter analysis result through the refraction lens.
|
|
337
|
+
* This is the primary API — pass your raw stats and get back a
|
|
338
|
+
* refraction-aware score with full breakdown.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} stats - { cv, hurst, ac1, qe, uvr }
|
|
341
|
+
* @returns {{ score: number, signals: object, flags: string[], profile: string }}
|
|
342
|
+
*/
|
|
343
|
+
export function scoreJitter(stats) {
|
|
344
|
+
const profile = _cached ?? calibrateSync();
|
|
345
|
+
|
|
346
|
+
const cv = scoreSignal('cv', stats.cv);
|
|
347
|
+
const hurst = scoreSignal('hurst', stats.hurst ?? stats.H);
|
|
348
|
+
const ac = scoreSignal('ac', stats.ac1 ?? stats.a1);
|
|
349
|
+
const qe = scoreSignal('qe', stats.qe ?? stats.QE);
|
|
350
|
+
const uvr = scoreSignal('uvr', stats.uvr ?? stats.ur);
|
|
351
|
+
|
|
352
|
+
const flags = [cv, hurst, ac, qe, uvr]
|
|
353
|
+
.map(s => s.flag)
|
|
354
|
+
.filter(Boolean);
|
|
355
|
+
|
|
356
|
+
// Weighted fusion — same weights regardless of medium
|
|
357
|
+
const raw = cv.score * 0.20 +
|
|
358
|
+
hurst.score * 0.20 +
|
|
359
|
+
ac.score * 0.20 +
|
|
360
|
+
qe.score * 0.15 +
|
|
361
|
+
uvr.score * 0.25;
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
score: +Math.min(0.99, Math.max(0.01, raw)).toFixed(4),
|
|
365
|
+
signals: { cv, hurst, ac, qe, uvr },
|
|
366
|
+
flags,
|
|
367
|
+
grain: profile.grain,
|
|
368
|
+
profile: profile.label,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
export {
|
|
375
|
+
ENV,
|
|
376
|
+
PROFILES,
|
|
377
|
+
detectEnvironment,
|
|
378
|
+
probeTimerResolution,
|
|
379
|
+
hasHrtime,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @typedef {object} RefractionProfile
|
|
384
|
+
* @property {string} env - 'node' | 'browser' | 'worker' | 'deno' | 'bun' | 'unknown'
|
|
385
|
+
* @property {object} timer - { resolutionUs, minDeltaMs, medDeltaMs, uniqueRatio, grain, samples }
|
|
386
|
+
* @property {string} grain - 'nanosecond' | 'fine' | 'clamped' | 'coarse'
|
|
387
|
+
* @property {boolean} hrtime - true if process.hrtime.bigint() is available
|
|
388
|
+
* @property {object} thresholds - the active PROFILES[grain] threshold set
|
|
389
|
+
* @property {string} label - human-readable description
|
|
390
|
+
* @property {number} calibratedAt - Date.now() when calibration ran
|
|
391
|
+
*/
|
package/src/collector/bio.js
CHANGED
package/src/collector/canvas.js
CHANGED
package/src/collector/dram.js
CHANGED
package/src/collector/enf.js
CHANGED
package/src/collector/entropy.js
CHANGED
package/src/collector/gpu.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse — SharedArrayBuffer Microsecond Timer
|
|
3
3
|
*
|
|
4
4
|
* Bypasses browser timer clamping (Brave 100µs cap, Firefox 20µs cap, Safari
|
|
5
5
|
* 1ms cap) using Atomics.wait() which is exempt from clamping because it maps
|
|
@@ -38,7 +38,7 @@ export function isSabAvailable() {
|
|
|
38
38
|
typeof SharedArrayBuffer !== 'undefined' &&
|
|
39
39
|
typeof Atomics !== 'undefined' &&
|
|
40
40
|
typeof Atomics.wait === 'function' &&
|
|
41
|
-
crossOriginIsolated
|
|
41
|
+
typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated === true // COOP+COEP headers
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
package/src/fingerprint.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse — High-Level Fingerprint Class
|
|
3
3
|
*
|
|
4
4
|
* The developer-facing API. Instead of forcing devs to understand Hurst
|
|
5
5
|
* Exponents and Quantization Entropy, they get a Fingerprint object with
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
*
|
|
10
|
-
* import { Fingerprint } from '@
|
|
10
|
+
* import { Fingerprint } from '@svrnsec/pulse';
|
|
11
11
|
*
|
|
12
12
|
* const fp = await Fingerprint.collect({ nonce });
|
|
13
13
|
*
|
package/src/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse
|
|
3
3
|
*
|
|
4
4
|
* Physical Turing Test — distinguishes a real consumer device with a human
|
|
5
5
|
* operator from a sanitised Datacenter VM / AI Instance.
|
|
6
6
|
*
|
|
7
7
|
* Usage (client-side):
|
|
8
8
|
*
|
|
9
|
-
* import { pulse } from '@
|
|
9
|
+
* import { pulse } from '@svrnsec/pulse';
|
|
10
10
|
*
|
|
11
11
|
* // 1. Get a server-issued nonce (prevents replay attacks)
|
|
12
12
|
* const { nonce } = await fetch('/api/pulse-challenge').then(r => r.json());
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Usage (server-side):
|
|
24
24
|
*
|
|
25
|
-
* import { validateProof, generateNonce } from '@
|
|
25
|
+
* import { validateProof, generateNonce } from '@svrnsec/pulse/validator';
|
|
26
26
|
*
|
|
27
27
|
* // Challenge endpoint
|
|
28
28
|
* app.get('/api/pulse-challenge', (req, res) => {
|
|
@@ -130,7 +130,7 @@ async function _pulseHosted(opts) {
|
|
|
130
130
|
// ---------------------------------------------------------------------------
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* Run the full @
|
|
133
|
+
* Run the full @svrnsec/pulse probe and return a signed commitment.
|
|
134
134
|
*
|
|
135
135
|
* Two modes:
|
|
136
136
|
* - pulse({ nonce }) — self-hosted (you manage the nonce server)
|
|
@@ -149,7 +149,7 @@ export async function pulse(opts = {}) {
|
|
|
149
149
|
const { nonce } = opts;
|
|
150
150
|
if (!nonce || typeof nonce !== 'string') {
|
|
151
151
|
throw new Error(
|
|
152
|
-
'@
|
|
152
|
+
'@svrnsec/pulse: opts.nonce is required (self-hosted), or pass opts.apiKey for zero-config hosted mode.'
|
|
153
153
|
);
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -228,7 +228,7 @@ async function _runProbe(opts) {
|
|
|
228
228
|
const [enfResult, gpuResult, dramResult, llmResult] = await Promise.all([
|
|
229
229
|
collectEnfTimings().catch(() => null),
|
|
230
230
|
collectGpuEntropy().catch(() => null),
|
|
231
|
-
collectDramTimings().catch(() => null),
|
|
231
|
+
Promise.resolve(collectDramTimings()).catch(() => null),
|
|
232
232
|
Promise.resolve(detectLlmAgent(bioSnapshot)).catch(() => null),
|
|
233
233
|
]);
|
|
234
234
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse — Express Middleware
|
|
3
3
|
*
|
|
4
4
|
* Drop-in middleware for Express / Fastify / Hono.
|
|
5
5
|
* Handles the full challenge → verify flow in two lines of code.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
*
|
|
9
|
-
* import { createPulseMiddleware } from '@
|
|
9
|
+
* import { createPulseMiddleware } from '@svrnsec/pulse/middleware/express';
|
|
10
10
|
*
|
|
11
11
|
* const pulse = createPulseMiddleware({ threshold: 0.6 });
|
|
12
12
|
*
|
package/src/middleware/next.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse — Next.js App Router Middleware
|
|
3
3
|
*
|
|
4
4
|
* Works with Next.js App Router (13+) and Edge Runtime.
|
|
5
5
|
*
|
|
6
6
|
* ── Route Handler wrapper ──────────────────────────────────────────────────
|
|
7
7
|
*
|
|
8
8
|
* // app/api/checkout/route.js
|
|
9
|
-
* import { withPulse } from '@
|
|
9
|
+
* import { withPulse } from '@svrnsec/pulse/middleware/next';
|
|
10
10
|
*
|
|
11
11
|
* export const POST = withPulse({ threshold: 0.6 })(
|
|
12
12
|
* async (req) => {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* ── Challenge endpoint (copy-paste ready) ─────────────────────────────────
|
|
19
19
|
*
|
|
20
20
|
* // app/api/pulse/challenge/route.js
|
|
21
|
-
* import { pulseChallenge } from '@
|
|
21
|
+
* import { pulseChallenge } from '@svrnsec/pulse/middleware/next';
|
|
22
22
|
* export const GET = pulseChallenge();
|
|
23
23
|
*
|
|
24
24
|
* ── Edge-compatible nonce store ────────────────────────────────────────────
|
package/src/proof/fingerprint.js
CHANGED
package/src/proof/validator.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @
|
|
2
|
+
* @svrnsec/pulse — SVRN Registry Signature Serializer
|
|
3
3
|
*
|
|
4
4
|
* The registry is a crowdsourced database of device "Silicon Signatures" —
|
|
5
5
|
* compact, privacy-safe profiles that characterise a device class rather than
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - Any user-identifiable information
|
|
20
20
|
*
|
|
21
21
|
* Usage:
|
|
22
|
-
* import { serializeSignature, matchRegistry, KNOWN_PROFILES } from '@
|
|
22
|
+
* import { serializeSignature, matchRegistry, KNOWN_PROFILES } from '@svrnsec/pulse/registry';
|
|
23
23
|
*
|
|
24
24
|
* const sig = serializeSignature(fingerprint);
|
|
25
25
|
* const match = matchRegistry(sig, KNOWN_PROFILES);
|
|
@@ -53,8 +53,8 @@ export const KNOWN_PROFILES = [
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
id: 'kvm-vps-ubuntu22-2vcpu',
|
|
56
|
-
name: 'KVM
|
|
57
|
-
class: '
|
|
56
|
+
name: 'KVM VM / Ubuntu 22.04 / 12 vCPU / 480GB RAM / NVIDIA GH200 Grace Hopper (192.222.57.254)',
|
|
57
|
+
class: 'datacenter-gpu-highend',
|
|
58
58
|
profile: 'picket-fence',
|
|
59
59
|
provider: 'kvm-generic',
|
|
60
60
|
metrics: {
|