@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
package/src/index.js
CHANGED
|
@@ -1,342 +1,342 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @
|
|
3
|
-
*
|
|
4
|
-
* Physical Turing Test — distinguishes a real consumer device with a human
|
|
5
|
-
* operator from a sanitised Datacenter VM / AI Instance.
|
|
6
|
-
*
|
|
7
|
-
* Usage (client-side):
|
|
8
|
-
*
|
|
9
|
-
* import { pulse } from '@
|
|
10
|
-
*
|
|
11
|
-
* // 1. Get a server-issued nonce (prevents replay attacks)
|
|
12
|
-
* const { nonce } = await fetch('/api/pulse-challenge').then(r => r.json());
|
|
13
|
-
*
|
|
14
|
-
* // 2. Run the probe (takes ~3-5 seconds)
|
|
15
|
-
* const { payload, hash } = await pulse({ nonce });
|
|
16
|
-
*
|
|
17
|
-
* // 3. Send to your server
|
|
18
|
-
* const verdict = await fetch('/api/pulse-verify', {
|
|
19
|
-
* method: 'POST',
|
|
20
|
-
* body: JSON.stringify({ payload, hash }),
|
|
21
|
-
* }).then(r => r.json());
|
|
22
|
-
*
|
|
23
|
-
* Usage (server-side):
|
|
24
|
-
*
|
|
25
|
-
* import { validateProof, generateNonce } from '@
|
|
26
|
-
*
|
|
27
|
-
* // Challenge endpoint
|
|
28
|
-
* app.get('/api/pulse-challenge', (req, res) => {
|
|
29
|
-
* const nonce = generateNonce();
|
|
30
|
-
* await redis.set(`pulse:nonce:${nonce}`, '1', 'EX', 300); // 5-min TTL
|
|
31
|
-
* res.json({ nonce });
|
|
32
|
-
* });
|
|
33
|
-
*
|
|
34
|
-
* // Verify endpoint
|
|
35
|
-
* app.post('/api/pulse-verify', async (req, res) => {
|
|
36
|
-
* const { payload, hash } = req.body;
|
|
37
|
-
* const result = await validateProof(payload, hash, {
|
|
38
|
-
* checkNonce: async (n) => {
|
|
39
|
-
* const ok = await redis.del(`pulse:nonce:${n}`);
|
|
40
|
-
* return ok === 1; // true only if nonce existed and was consumed
|
|
41
|
-
* },
|
|
42
|
-
* });
|
|
43
|
-
* res.json(result);
|
|
44
|
-
* });
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
import { collectEntropy } from './collector/entropy.js';
|
|
48
|
-
import { BioCollector } from './collector/bio.js';
|
|
49
|
-
import { collectCanvasFingerprint } from './collector/canvas.js';
|
|
50
|
-
import { collectAudioJitter } from './analysis/audio.js';
|
|
51
|
-
import { classifyJitter } from './analysis/jitter.js';
|
|
52
|
-
import { buildProof, buildCommitment } from './proof/fingerprint.js';
|
|
53
|
-
import { collectGpuEntropy } from './collector/gpu.js';
|
|
54
|
-
import { collectDramTimings } from './collector/dram.js';
|
|
55
|
-
import { collectEnfTimings } from './collector/enf.js';
|
|
56
|
-
import { detectLlmAgent } from './analysis/llm.js';
|
|
57
|
-
import { notifyOnExit } from './update-notifier.js';
|
|
58
|
-
|
|
59
|
-
// Register background update check — fires once at process startup.
|
|
60
|
-
// Shows a styled notification box after the process exits if a newer version
|
|
61
|
-
// is available. No-op in browser environments and non-TTY outputs.
|
|
62
|
-
notifyOnExit();
|
|
63
|
-
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
// Hosted API mode — pulse({ apiKey }) with zero server setup
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Run pulse() against the sovereign hosted API.
|
|
70
|
-
* Fetches nonce, runs probe locally (WASM still on device), submits proof.
|
|
71
|
-
*
|
|
72
|
-
* @param {object} opts — same as pulse(), plus apiKey + apiUrl
|
|
73
|
-
* @returns {Promise<{ payload, hash, result }>}
|
|
74
|
-
*/
|
|
75
|
-
async function _pulseHosted(opts) {
|
|
76
|
-
const {
|
|
77
|
-
apiKey,
|
|
78
|
-
apiUrl = 'https://api.sovereign.dev',
|
|
79
|
-
iterations = 200,
|
|
80
|
-
matrixSize = 64,
|
|
81
|
-
bioWindowMs = 3_000,
|
|
82
|
-
phased = true,
|
|
83
|
-
adaptive = true,
|
|
84
|
-
adaptiveThreshold = 0.85,
|
|
85
|
-
requireBio = false,
|
|
86
|
-
wasmPath,
|
|
87
|
-
onProgress,
|
|
88
|
-
verifyOptions = {},
|
|
89
|
-
} = opts;
|
|
90
|
-
|
|
91
|
-
// 1. Fetch nonce from hosted challenge endpoint
|
|
92
|
-
const challengeRes = await fetch(`${apiUrl}/v1/challenge`, {
|
|
93
|
-
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
94
|
-
});
|
|
95
|
-
if (!challengeRes.ok) {
|
|
96
|
-
const body = await challengeRes.json().catch(() => ({}));
|
|
97
|
-
throw new Error(`[pulse] Challenge failed (${challengeRes.status}): ${body.message ?? 'unknown error'}`);
|
|
98
|
-
}
|
|
99
|
-
const { nonce } = await challengeRes.json();
|
|
100
|
-
|
|
101
|
-
// 2. Run the local probe (WASM, bio, canvas, audio — all on device)
|
|
102
|
-
const commitment = await _runProbe({
|
|
103
|
-
nonce, iterations, matrixSize, bioWindowMs,
|
|
104
|
-
phased, adaptive, adaptiveThreshold, requireBio,
|
|
105
|
-
wasmPath, onProgress,
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// 3. Submit proof to hosted verify endpoint
|
|
109
|
-
const verifyRes = await fetch(`${apiUrl}/v1/verify`, {
|
|
110
|
-
method: 'POST',
|
|
111
|
-
headers: {
|
|
112
|
-
'Content-Type': 'application/json',
|
|
113
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
114
|
-
},
|
|
115
|
-
body: JSON.stringify({
|
|
116
|
-
payload: commitment.payload,
|
|
117
|
-
hash: commitment.hash,
|
|
118
|
-
options: verifyOptions,
|
|
119
|
-
}),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const result = await verifyRes.json();
|
|
123
|
-
|
|
124
|
-
// Return commitment + server result for convenience
|
|
125
|
-
return { ...commitment, result };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
// pulse() — main entry point
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Run the full @
|
|
134
|
-
*
|
|
135
|
-
* Two modes:
|
|
136
|
-
* - pulse({ nonce }) — self-hosted (you manage the nonce server)
|
|
137
|
-
* - pulse({ apiKey }) — hosted API (zero server setup required)
|
|
138
|
-
*
|
|
139
|
-
* @param {PulseOptions} opts
|
|
140
|
-
* @returns {Promise<PulseCommitment>}
|
|
141
|
-
*/
|
|
142
|
-
export async function pulse(opts = {}) {
|
|
143
|
-
// ── Hosted API mode ────────────────────────────────────────────────────────
|
|
144
|
-
if (opts.apiKey) {
|
|
145
|
-
return _pulseHosted(opts);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── Self-hosted mode ───────────────────────────────────────────────────────
|
|
149
|
-
const { nonce } = opts;
|
|
150
|
-
if (!nonce || typeof nonce !== 'string') {
|
|
151
|
-
throw new Error(
|
|
152
|
-
'@
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return _runProbe(opts);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Internal probe runner — shared between self-hosted and hosted API modes.
|
|
161
|
-
* @private
|
|
162
|
-
*/
|
|
163
|
-
async function _runProbe(opts) {
|
|
164
|
-
const {
|
|
165
|
-
nonce,
|
|
166
|
-
timeout = 8_000,
|
|
167
|
-
iterations = 200,
|
|
168
|
-
matrixSize = 64,
|
|
169
|
-
bioWindowMs = 3_000,
|
|
170
|
-
phased = true,
|
|
171
|
-
adaptive = true,
|
|
172
|
-
adaptiveThreshold = 0.85,
|
|
173
|
-
requireBio = false,
|
|
174
|
-
wasmPath,
|
|
175
|
-
onProgress,
|
|
176
|
-
} = opts;
|
|
177
|
-
|
|
178
|
-
_emit(onProgress, 'start');
|
|
179
|
-
|
|
180
|
-
// ── Phase 1: Start bio collector immediately (collects events over time) ──
|
|
181
|
-
const bio = new BioCollector();
|
|
182
|
-
bio.start();
|
|
183
|
-
|
|
184
|
-
// ── Phase 2: Parallel collection ──────────────────────────────────────────
|
|
185
|
-
const raceTimeout = new Promise((_, reject) =>
|
|
186
|
-
setTimeout(() => reject(new Error('pulse() timed out')), timeout)
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
let entropyResult, canvasResult, audioResult;
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
[entropyResult, canvasResult, audioResult] = await Promise.race([
|
|
193
|
-
Promise.all([
|
|
194
|
-
collectEntropy({
|
|
195
|
-
iterations, matrixSize, phased, adaptive, adaptiveThreshold, wasmPath,
|
|
196
|
-
onBatch: (meta) => _emit(onProgress, 'entropy_batch', meta),
|
|
197
|
-
}).then(r => { _emit(onProgress, 'entropy_done'); return r; }),
|
|
198
|
-
collectCanvasFingerprint()
|
|
199
|
-
.then(r => { _emit(onProgress, 'canvas_done'); return r; }),
|
|
200
|
-
collectAudioJitter({ durationMs: Math.min(bioWindowMs, 2_000) })
|
|
201
|
-
.then(r => { _emit(onProgress, 'audio_done'); return r; }),
|
|
202
|
-
]),
|
|
203
|
-
raceTimeout,
|
|
204
|
-
]);
|
|
205
|
-
} catch (err) {
|
|
206
|
-
bio.stop();
|
|
207
|
-
throw err;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ── Phase 3: Bio snapshot ─────────────────────────────────────────────────
|
|
211
|
-
const bioElapsed = Date.now() - entropyResult.collectedAt;
|
|
212
|
-
const bioRemain = Math.max(0, bioWindowMs - bioElapsed);
|
|
213
|
-
if (bioRemain > 0) await _sleep(bioRemain);
|
|
214
|
-
|
|
215
|
-
bio.stop();
|
|
216
|
-
const bioSnapshot = bio.snapshot(entropyResult.timings);
|
|
217
|
-
|
|
218
|
-
if (requireBio && !bioSnapshot.hasActivity) {
|
|
219
|
-
throw new Error('@svrnsec/pulse: no bio activity detected (requireBio=true)');
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
_emit(onProgress, 'bio_done');
|
|
223
|
-
|
|
224
|
-
// ── Phase 4: Extended signal collection (non-blocking, best-effort) ───────
|
|
225
|
-
// ENF, GPU, DRAM, and LLM detectors run in parallel after the core probe.
|
|
226
|
-
// Each gracefully returns a null/unavailable result if the environment does
|
|
227
|
-
// not support it (e.g. no WebGPU, no SharedArrayBuffer, no bio events).
|
|
228
|
-
const [enfResult, gpuResult, dramResult, llmResult] = await Promise.all([
|
|
229
|
-
collectEnfTimings().catch(() => null),
|
|
230
|
-
collectGpuEntropy().catch(() => null),
|
|
231
|
-
collectDramTimings().catch(() => null),
|
|
232
|
-
Promise.resolve(detectLlmAgent(bioSnapshot)).catch(() => null),
|
|
233
|
-
]);
|
|
234
|
-
|
|
235
|
-
_emit(onProgress, 'extended_done', {
|
|
236
|
-
enf: enfResult?.verdict,
|
|
237
|
-
gpu: gpuResult?.verdict,
|
|
238
|
-
dram: dramResult?.verdict,
|
|
239
|
-
llm: llmResult?.aiConf,
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// ── Phase 5: Jitter analysis ───────────────────────────────────────────────
|
|
243
|
-
const jitterAnalysis = classifyJitter(entropyResult.timings, {
|
|
244
|
-
autocorrelations: entropyResult.autocorrelations,
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
_emit(onProgress, 'analysis_done');
|
|
248
|
-
|
|
249
|
-
// ── Phase 6: Build proof & commitment ─────────────────────────────────────
|
|
250
|
-
const payload = buildProof({
|
|
251
|
-
entropy: entropyResult,
|
|
252
|
-
jitter: jitterAnalysis,
|
|
253
|
-
bio: bioSnapshot,
|
|
254
|
-
canvas: canvasResult,
|
|
255
|
-
audio: audioResult,
|
|
256
|
-
enf: enfResult,
|
|
257
|
-
gpu: gpuResult,
|
|
258
|
-
dram: dramResult,
|
|
259
|
-
llm: llmResult,
|
|
260
|
-
nonce,
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
const commitment = buildCommitment(payload);
|
|
264
|
-
|
|
265
|
-
_emit(onProgress, 'complete', {
|
|
266
|
-
score: jitterAnalysis.score,
|
|
267
|
-
confidence: _scoreToLabel(jitterAnalysis.score),
|
|
268
|
-
flags: jitterAnalysis.flags,
|
|
269
|
-
enf: enfResult?.verdict,
|
|
270
|
-
gpu: gpuResult?.verdict,
|
|
271
|
-
dram: dramResult?.verdict,
|
|
272
|
-
llmConf: llmResult?.aiConf ?? null,
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
return { ...commitment, extended: { enf: enfResult, gpu: gpuResult, dram: dramResult, llm: llmResult } };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* @typedef {object} PulseOptions
|
|
280
|
-
* @property {string} nonce - server-issued challenge nonce (required)
|
|
281
|
-
* @property {number} [timeout=6000] - max ms before throwing
|
|
282
|
-
* @property {number} [iterations=200]
|
|
283
|
-
* @property {number} [matrixSize=64]
|
|
284
|
-
* @property {number} [bioWindowMs=3000]
|
|
285
|
-
* @property {boolean} [requireBio=false]
|
|
286
|
-
* @property {string} [wasmPath] - custom WASM binary URL/path
|
|
287
|
-
* @property {Function} [onProgress] - callback(stage, meta?) for progress events
|
|
288
|
-
*/
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* @typedef {object} PulseCommitment
|
|
292
|
-
* @property {import('./proof/fingerprint.js').ProofPayload} payload
|
|
293
|
-
* @property {string} hash - hex BLAKE3 commitment
|
|
294
|
-
*/
|
|
295
|
-
|
|
296
|
-
// ---------------------------------------------------------------------------
|
|
297
|
-
// Re-exports for convenience
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
|
|
300
|
-
// High-level developer API (the easiest way to use this package)
|
|
301
|
-
export { Fingerprint } from './fingerprint.js';
|
|
302
|
-
|
|
303
|
-
// Analysis modules for advanced / custom integrations
|
|
304
|
-
export { runHeuristicEngine } from './analysis/heuristic.js';
|
|
305
|
-
export { detectProvider } from './analysis/provider.js';
|
|
306
|
-
|
|
307
|
-
// Server-side validation
|
|
308
|
-
export { generateNonce } from './proof/validator.js';
|
|
309
|
-
export { validateProof } from './proof/validator.js';
|
|
310
|
-
|
|
311
|
-
// Extended signal collectors (also available as named sub-path exports)
|
|
312
|
-
export { collectGpuEntropy } from './collector/gpu.js';
|
|
313
|
-
export { collectDramTimings } from './collector/dram.js';
|
|
314
|
-
export { collectEnfTimings } from './collector/enf.js';
|
|
315
|
-
export { detectLlmAgent } from './analysis/llm.js';
|
|
316
|
-
|
|
317
|
-
// Terminal utilities — pretty probe results in Node.js server contexts
|
|
318
|
-
export { renderProbeResult, renderError, renderInlineUpdateHint } from './terminal.js';
|
|
319
|
-
|
|
320
|
-
// Version introspection
|
|
321
|
-
export { CURRENT_VERSION, checkForUpdate } from './update-notifier.js';
|
|
322
|
-
|
|
323
|
-
// ---------------------------------------------------------------------------
|
|
324
|
-
// Internal helpers
|
|
325
|
-
// ---------------------------------------------------------------------------
|
|
326
|
-
|
|
327
|
-
function _sleep(ms) {
|
|
328
|
-
return new Promise(r => setTimeout(r, ms));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function _emit(fn, stage, meta = {}) {
|
|
332
|
-
if (typeof fn === 'function') {
|
|
333
|
-
try { fn(stage, meta); } catch (_) {}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function _scoreToLabel(score) {
|
|
338
|
-
if (score >= 0.75) return 'high';
|
|
339
|
-
if (score >= 0.55) return 'medium';
|
|
340
|
-
if (score >= 0.35) return 'low';
|
|
341
|
-
return 'rejected';
|
|
342
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse
|
|
3
|
+
*
|
|
4
|
+
* Physical Turing Test — distinguishes a real consumer device with a human
|
|
5
|
+
* operator from a sanitised Datacenter VM / AI Instance.
|
|
6
|
+
*
|
|
7
|
+
* Usage (client-side):
|
|
8
|
+
*
|
|
9
|
+
* import { pulse } from '@svrnsec/pulse';
|
|
10
|
+
*
|
|
11
|
+
* // 1. Get a server-issued nonce (prevents replay attacks)
|
|
12
|
+
* const { nonce } = await fetch('/api/pulse-challenge').then(r => r.json());
|
|
13
|
+
*
|
|
14
|
+
* // 2. Run the probe (takes ~3-5 seconds)
|
|
15
|
+
* const { payload, hash } = await pulse({ nonce });
|
|
16
|
+
*
|
|
17
|
+
* // 3. Send to your server
|
|
18
|
+
* const verdict = await fetch('/api/pulse-verify', {
|
|
19
|
+
* method: 'POST',
|
|
20
|
+
* body: JSON.stringify({ payload, hash }),
|
|
21
|
+
* }).then(r => r.json());
|
|
22
|
+
*
|
|
23
|
+
* Usage (server-side):
|
|
24
|
+
*
|
|
25
|
+
* import { validateProof, generateNonce } from '@svrnsec/pulse/validator';
|
|
26
|
+
*
|
|
27
|
+
* // Challenge endpoint
|
|
28
|
+
* app.get('/api/pulse-challenge', (req, res) => {
|
|
29
|
+
* const nonce = generateNonce();
|
|
30
|
+
* await redis.set(`pulse:nonce:${nonce}`, '1', 'EX', 300); // 5-min TTL
|
|
31
|
+
* res.json({ nonce });
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* // Verify endpoint
|
|
35
|
+
* app.post('/api/pulse-verify', async (req, res) => {
|
|
36
|
+
* const { payload, hash } = req.body;
|
|
37
|
+
* const result = await validateProof(payload, hash, {
|
|
38
|
+
* checkNonce: async (n) => {
|
|
39
|
+
* const ok = await redis.del(`pulse:nonce:${n}`);
|
|
40
|
+
* return ok === 1; // true only if nonce existed and was consumed
|
|
41
|
+
* },
|
|
42
|
+
* });
|
|
43
|
+
* res.json(result);
|
|
44
|
+
* });
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { collectEntropy } from './collector/entropy.js';
|
|
48
|
+
import { BioCollector } from './collector/bio.js';
|
|
49
|
+
import { collectCanvasFingerprint } from './collector/canvas.js';
|
|
50
|
+
import { collectAudioJitter } from './analysis/audio.js';
|
|
51
|
+
import { classifyJitter } from './analysis/jitter.js';
|
|
52
|
+
import { buildProof, buildCommitment } from './proof/fingerprint.js';
|
|
53
|
+
import { collectGpuEntropy } from './collector/gpu.js';
|
|
54
|
+
import { collectDramTimings } from './collector/dram.js';
|
|
55
|
+
import { collectEnfTimings } from './collector/enf.js';
|
|
56
|
+
import { detectLlmAgent } from './analysis/llm.js';
|
|
57
|
+
import { notifyOnExit } from './update-notifier.js';
|
|
58
|
+
|
|
59
|
+
// Register background update check — fires once at process startup.
|
|
60
|
+
// Shows a styled notification box after the process exits if a newer version
|
|
61
|
+
// is available. No-op in browser environments and non-TTY outputs.
|
|
62
|
+
notifyOnExit();
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Hosted API mode — pulse({ apiKey }) with zero server setup
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run pulse() against the sovereign hosted API.
|
|
70
|
+
* Fetches nonce, runs probe locally (WASM still on device), submits proof.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} opts — same as pulse(), plus apiKey + apiUrl
|
|
73
|
+
* @returns {Promise<{ payload, hash, result }>}
|
|
74
|
+
*/
|
|
75
|
+
async function _pulseHosted(opts) {
|
|
76
|
+
const {
|
|
77
|
+
apiKey,
|
|
78
|
+
apiUrl = 'https://api.sovereign.dev',
|
|
79
|
+
iterations = 200,
|
|
80
|
+
matrixSize = 64,
|
|
81
|
+
bioWindowMs = 3_000,
|
|
82
|
+
phased = true,
|
|
83
|
+
adaptive = true,
|
|
84
|
+
adaptiveThreshold = 0.85,
|
|
85
|
+
requireBio = false,
|
|
86
|
+
wasmPath,
|
|
87
|
+
onProgress,
|
|
88
|
+
verifyOptions = {},
|
|
89
|
+
} = opts;
|
|
90
|
+
|
|
91
|
+
// 1. Fetch nonce from hosted challenge endpoint
|
|
92
|
+
const challengeRes = await fetch(`${apiUrl}/v1/challenge`, {
|
|
93
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
94
|
+
});
|
|
95
|
+
if (!challengeRes.ok) {
|
|
96
|
+
const body = await challengeRes.json().catch(() => ({}));
|
|
97
|
+
throw new Error(`[pulse] Challenge failed (${challengeRes.status}): ${body.message ?? 'unknown error'}`);
|
|
98
|
+
}
|
|
99
|
+
const { nonce } = await challengeRes.json();
|
|
100
|
+
|
|
101
|
+
// 2. Run the local probe (WASM, bio, canvas, audio — all on device)
|
|
102
|
+
const commitment = await _runProbe({
|
|
103
|
+
nonce, iterations, matrixSize, bioWindowMs,
|
|
104
|
+
phased, adaptive, adaptiveThreshold, requireBio,
|
|
105
|
+
wasmPath, onProgress,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 3. Submit proof to hosted verify endpoint
|
|
109
|
+
const verifyRes = await fetch(`${apiUrl}/v1/verify`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
payload: commitment.payload,
|
|
117
|
+
hash: commitment.hash,
|
|
118
|
+
options: verifyOptions,
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = await verifyRes.json();
|
|
123
|
+
|
|
124
|
+
// Return commitment + server result for convenience
|
|
125
|
+
return { ...commitment, result };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// pulse() — main entry point
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run the full @svrnsec/pulse probe and return a signed commitment.
|
|
134
|
+
*
|
|
135
|
+
* Two modes:
|
|
136
|
+
* - pulse({ nonce }) — self-hosted (you manage the nonce server)
|
|
137
|
+
* - pulse({ apiKey }) — hosted API (zero server setup required)
|
|
138
|
+
*
|
|
139
|
+
* @param {PulseOptions} opts
|
|
140
|
+
* @returns {Promise<PulseCommitment>}
|
|
141
|
+
*/
|
|
142
|
+
export async function pulse(opts = {}) {
|
|
143
|
+
// ── Hosted API mode ────────────────────────────────────────────────────────
|
|
144
|
+
if (opts.apiKey) {
|
|
145
|
+
return _pulseHosted(opts);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Self-hosted mode ───────────────────────────────────────────────────────
|
|
149
|
+
const { nonce } = opts;
|
|
150
|
+
if (!nonce || typeof nonce !== 'string') {
|
|
151
|
+
throw new Error(
|
|
152
|
+
'@svrnsec/pulse: opts.nonce is required (self-hosted), or pass opts.apiKey for zero-config hosted mode.'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return _runProbe(opts);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Internal probe runner — shared between self-hosted and hosted API modes.
|
|
161
|
+
* @private
|
|
162
|
+
*/
|
|
163
|
+
async function _runProbe(opts) {
|
|
164
|
+
const {
|
|
165
|
+
nonce,
|
|
166
|
+
timeout = 8_000,
|
|
167
|
+
iterations = 200,
|
|
168
|
+
matrixSize = 64,
|
|
169
|
+
bioWindowMs = 3_000,
|
|
170
|
+
phased = true,
|
|
171
|
+
adaptive = true,
|
|
172
|
+
adaptiveThreshold = 0.85,
|
|
173
|
+
requireBio = false,
|
|
174
|
+
wasmPath,
|
|
175
|
+
onProgress,
|
|
176
|
+
} = opts;
|
|
177
|
+
|
|
178
|
+
_emit(onProgress, 'start');
|
|
179
|
+
|
|
180
|
+
// ── Phase 1: Start bio collector immediately (collects events over time) ──
|
|
181
|
+
const bio = new BioCollector();
|
|
182
|
+
bio.start();
|
|
183
|
+
|
|
184
|
+
// ── Phase 2: Parallel collection ──────────────────────────────────────────
|
|
185
|
+
const raceTimeout = new Promise((_, reject) =>
|
|
186
|
+
setTimeout(() => reject(new Error('pulse() timed out')), timeout)
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
let entropyResult, canvasResult, audioResult;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
[entropyResult, canvasResult, audioResult] = await Promise.race([
|
|
193
|
+
Promise.all([
|
|
194
|
+
collectEntropy({
|
|
195
|
+
iterations, matrixSize, phased, adaptive, adaptiveThreshold, wasmPath,
|
|
196
|
+
onBatch: (meta) => _emit(onProgress, 'entropy_batch', meta),
|
|
197
|
+
}).then(r => { _emit(onProgress, 'entropy_done'); return r; }),
|
|
198
|
+
collectCanvasFingerprint()
|
|
199
|
+
.then(r => { _emit(onProgress, 'canvas_done'); return r; }),
|
|
200
|
+
collectAudioJitter({ durationMs: Math.min(bioWindowMs, 2_000) })
|
|
201
|
+
.then(r => { _emit(onProgress, 'audio_done'); return r; }),
|
|
202
|
+
]),
|
|
203
|
+
raceTimeout,
|
|
204
|
+
]);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
bio.stop();
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Phase 3: Bio snapshot ─────────────────────────────────────────────────
|
|
211
|
+
const bioElapsed = Date.now() - entropyResult.collectedAt;
|
|
212
|
+
const bioRemain = Math.max(0, bioWindowMs - bioElapsed);
|
|
213
|
+
if (bioRemain > 0) await _sleep(bioRemain);
|
|
214
|
+
|
|
215
|
+
bio.stop();
|
|
216
|
+
const bioSnapshot = bio.snapshot(entropyResult.timings);
|
|
217
|
+
|
|
218
|
+
if (requireBio && !bioSnapshot.hasActivity) {
|
|
219
|
+
throw new Error('@svrnsec/pulse: no bio activity detected (requireBio=true)');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_emit(onProgress, 'bio_done');
|
|
223
|
+
|
|
224
|
+
// ── Phase 4: Extended signal collection (non-blocking, best-effort) ───────
|
|
225
|
+
// ENF, GPU, DRAM, and LLM detectors run in parallel after the core probe.
|
|
226
|
+
// Each gracefully returns a null/unavailable result if the environment does
|
|
227
|
+
// not support it (e.g. no WebGPU, no SharedArrayBuffer, no bio events).
|
|
228
|
+
const [enfResult, gpuResult, dramResult, llmResult] = await Promise.all([
|
|
229
|
+
collectEnfTimings().catch(() => null),
|
|
230
|
+
collectGpuEntropy().catch(() => null),
|
|
231
|
+
Promise.resolve(collectDramTimings()).catch(() => null),
|
|
232
|
+
Promise.resolve(detectLlmAgent(bioSnapshot)).catch(() => null),
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
_emit(onProgress, 'extended_done', {
|
|
236
|
+
enf: enfResult?.verdict,
|
|
237
|
+
gpu: gpuResult?.verdict,
|
|
238
|
+
dram: dramResult?.verdict,
|
|
239
|
+
llm: llmResult?.aiConf,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── Phase 5: Jitter analysis ───────────────────────────────────────────────
|
|
243
|
+
const jitterAnalysis = classifyJitter(entropyResult.timings, {
|
|
244
|
+
autocorrelations: entropyResult.autocorrelations,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
_emit(onProgress, 'analysis_done');
|
|
248
|
+
|
|
249
|
+
// ── Phase 6: Build proof & commitment ─────────────────────────────────────
|
|
250
|
+
const payload = buildProof({
|
|
251
|
+
entropy: entropyResult,
|
|
252
|
+
jitter: jitterAnalysis,
|
|
253
|
+
bio: bioSnapshot,
|
|
254
|
+
canvas: canvasResult,
|
|
255
|
+
audio: audioResult,
|
|
256
|
+
enf: enfResult,
|
|
257
|
+
gpu: gpuResult,
|
|
258
|
+
dram: dramResult,
|
|
259
|
+
llm: llmResult,
|
|
260
|
+
nonce,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const commitment = buildCommitment(payload);
|
|
264
|
+
|
|
265
|
+
_emit(onProgress, 'complete', {
|
|
266
|
+
score: jitterAnalysis.score,
|
|
267
|
+
confidence: _scoreToLabel(jitterAnalysis.score),
|
|
268
|
+
flags: jitterAnalysis.flags,
|
|
269
|
+
enf: enfResult?.verdict,
|
|
270
|
+
gpu: gpuResult?.verdict,
|
|
271
|
+
dram: dramResult?.verdict,
|
|
272
|
+
llmConf: llmResult?.aiConf ?? null,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return { ...commitment, extended: { enf: enfResult, gpu: gpuResult, dram: dramResult, llm: llmResult } };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* @typedef {object} PulseOptions
|
|
280
|
+
* @property {string} nonce - server-issued challenge nonce (required)
|
|
281
|
+
* @property {number} [timeout=6000] - max ms before throwing
|
|
282
|
+
* @property {number} [iterations=200]
|
|
283
|
+
* @property {number} [matrixSize=64]
|
|
284
|
+
* @property {number} [bioWindowMs=3000]
|
|
285
|
+
* @property {boolean} [requireBio=false]
|
|
286
|
+
* @property {string} [wasmPath] - custom WASM binary URL/path
|
|
287
|
+
* @property {Function} [onProgress] - callback(stage, meta?) for progress events
|
|
288
|
+
*/
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @typedef {object} PulseCommitment
|
|
292
|
+
* @property {import('./proof/fingerprint.js').ProofPayload} payload
|
|
293
|
+
* @property {string} hash - hex BLAKE3 commitment
|
|
294
|
+
*/
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Re-exports for convenience
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
// High-level developer API (the easiest way to use this package)
|
|
301
|
+
export { Fingerprint } from './fingerprint.js';
|
|
302
|
+
|
|
303
|
+
// Analysis modules for advanced / custom integrations
|
|
304
|
+
export { runHeuristicEngine } from './analysis/heuristic.js';
|
|
305
|
+
export { detectProvider } from './analysis/provider.js';
|
|
306
|
+
|
|
307
|
+
// Server-side validation
|
|
308
|
+
export { generateNonce } from './proof/validator.js';
|
|
309
|
+
export { validateProof } from './proof/validator.js';
|
|
310
|
+
|
|
311
|
+
// Extended signal collectors (also available as named sub-path exports)
|
|
312
|
+
export { collectGpuEntropy } from './collector/gpu.js';
|
|
313
|
+
export { collectDramTimings } from './collector/dram.js';
|
|
314
|
+
export { collectEnfTimings } from './collector/enf.js';
|
|
315
|
+
export { detectLlmAgent } from './analysis/llm.js';
|
|
316
|
+
|
|
317
|
+
// Terminal utilities — pretty probe results in Node.js server contexts
|
|
318
|
+
export { renderProbeResult, renderError, renderInlineUpdateHint } from './terminal.js';
|
|
319
|
+
|
|
320
|
+
// Version introspection
|
|
321
|
+
export { CURRENT_VERSION, checkForUpdate } from './update-notifier.js';
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Internal helpers
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
function _sleep(ms) {
|
|
328
|
+
return new Promise(r => setTimeout(r, ms));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _emit(fn, stage, meta = {}) {
|
|
332
|
+
if (typeof fn === 'function') {
|
|
333
|
+
try { fn(stage, meta); } catch (_) {}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function _scoreToLabel(score) {
|
|
338
|
+
if (score >= 0.75) return 'high';
|
|
339
|
+
if (score >= 0.55) return 'medium';
|
|
340
|
+
if (score >= 0.35) return 'low';
|
|
341
|
+
return 'rejected';
|
|
342
|
+
}
|