@svrnsec/pulse 0.3.0 → 0.4.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/src/index.js ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * @sovereign/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 '@sovereign/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 '@sovereign/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 @sovereign/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
+ '@sovereign/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
+ 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
+ }