@svrnsec/pulse 0.7.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.
Files changed (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +883 -782
  3. package/SECURITY.md +86 -86
  4. package/bin/svrnsec-pulse.js +7 -7
  5. package/dist/{pulse.cjs.js → pulse.cjs} +6378 -6419
  6. package/dist/pulse.cjs.map +1 -0
  7. package/dist/pulse.esm.js +6379 -6420
  8. package/dist/pulse.esm.js.map +1 -1
  9. package/index.d.ts +895 -846
  10. package/package.json +185 -184
  11. package/pkg/pulse_core.js +174 -173
  12. package/src/analysis/audio.js +213 -213
  13. package/src/analysis/authenticityAudit.js +408 -393
  14. package/src/analysis/coherence.js +502 -502
  15. package/src/analysis/coordinatedBehavior.js +825 -804
  16. package/src/analysis/heuristic.js +428 -428
  17. package/src/analysis/jitter.js +446 -446
  18. package/src/analysis/llm.js +473 -472
  19. package/src/analysis/populationEntropy.js +404 -403
  20. package/src/analysis/provider.js +248 -248
  21. package/src/analysis/refraction.js +392 -391
  22. package/src/analysis/trustScore.js +356 -356
  23. package/src/cli/args.js +36 -36
  24. package/src/cli/commands/scan.js +192 -192
  25. package/src/cli/runner.js +157 -157
  26. package/src/collector/adaptive.js +200 -200
  27. package/src/collector/bio.js +297 -287
  28. package/src/collector/canvas.js +247 -239
  29. package/src/collector/dram.js +203 -203
  30. package/src/collector/enf.js +311 -311
  31. package/src/collector/entropy.js +195 -195
  32. package/src/collector/gpu.js +248 -245
  33. package/src/collector/idleAttestation.js +480 -480
  34. package/src/collector/sabTimer.js +189 -191
  35. package/src/fingerprint.js +475 -475
  36. package/src/index.js +342 -342
  37. package/src/integrations/react-native.js +462 -459
  38. package/src/integrations/react.js +184 -185
  39. package/src/middleware/express.js +155 -155
  40. package/src/middleware/next.js +174 -175
  41. package/src/proof/challenge.js +249 -249
  42. package/src/proof/engagementToken.js +426 -394
  43. package/src/proof/fingerprint.js +268 -268
  44. package/src/proof/validator.js +82 -142
  45. package/src/registry/serializer.js +349 -349
  46. package/src/terminal.js +263 -263
  47. package/src/update-notifier.js +259 -264
  48. package/dist/pulse.cjs.js.map +0 -1
@@ -1,459 +1,462 @@
1
- /**
2
- * @svrnsec/pulse — React Native Hook
3
- *
4
- * Runs the Physical Turing Test on iOS and Android.
5
- *
6
- * Mobile adds two signals that desktop cannot provide:
7
- *
8
- * 1. Accelerometer micro-tremor (8–12 Hz physiological band)
9
- * Human hands shake at 8–12 Hz involuntarily. This appears as a
10
- * continuous low-amplitude signal in the accelerometer. Emulators
11
- * (Android Emulator, iOS Simulator, any cloud device farm) have
12
- * zero or perfectly smooth accelerometer output. Real devices on
13
- * a desk still show ~0.002g RMS noise from building vibration and
14
- * HDD/fan resonance transmitted through the surface.
15
- *
16
- * 2. Touch event temporal fingerprint
17
- * Human touch-down → touch-up durations follow a log-normal
18
- * distribution (mean ~120ms, CV ~0.4). Automated taps from
19
- * Appium, UIAutomator, XCUITest, or an LLM agent are either
20
- * instantaneous (0ms dwell) or at a fixed scripted duration.
21
- *
22
- * 3. Gyroscope micro-rotation
23
- * Real hands holding a phone produce sub-degree rotation noise
24
- * at 0–5 Hz. An emulator or a phone in a robot testing rig has
25
- * near-zero gyroscope variance.
26
- *
27
- * Peer dependencies (install separately):
28
- * expo-sensors (Accelerometer, Gyroscope)
29
- * react-native (Platform, PanResponder)
30
- *
31
- * Both expo-sensors (Expo managed/bare workflow) and react-native are
32
- * already in every React Native project — this hook adds zero new deps.
33
- *
34
- * Usage:
35
- * import { usePulseNative } from '@svrnsec/pulse/react-native';
36
- *
37
- * function CheckoutScreen() {
38
- * const { run, isRunning, trustScore, verdict } = usePulseNative({
39
- * challengeUrl: '/api/challenge',
40
- * verifyUrl: '/api/verify',
41
- * });
42
- *
43
- * useEffect(() => { run(); }, []);
44
- *
45
- * if (trustScore) {
46
- * return <Text>TrustScore: {trustScore.score}/100 ({trustScore.grade})</Text>;
47
- * }
48
- * }
49
- */
50
-
51
- import { useState, useEffect, useRef, useCallback } from 'react';
52
-
53
- /* ─── Platform detection ─────────────────────────────────────────────────── */
54
-
55
- function _getPlatform() {
56
- try {
57
- const { Platform } = require('react-native');
58
- return Platform.OS; // 'ios' | 'android' | 'web'
59
- } catch {
60
- return 'unknown';
61
- }
62
- }
63
-
64
- /* ─── Sensor collector ───────────────────────────────────────────────────── */
65
-
66
- /**
67
- * Collect accelerometer + gyroscope samples for tremor analysis.
68
- * Returns a promise that resolves after `durationMs`.
69
- *
70
- * @param {number} durationMs
71
- * @returns {Promise<{ accel: number[][], gyro: number[][] }>}
72
- * accel[i] = [x, y, z] in g
73
- * gyro[i] = [x, y, z] in rad/s
74
- */
75
- async function _collectSensors(durationMs = 3000) {
76
- const accel = [];
77
- const gyro = [];
78
-
79
- try {
80
- const { Accelerometer, Gyroscope } = require('expo-sensors');
81
-
82
- Accelerometer.setUpdateInterval(10); // 100 Hz
83
- Gyroscope.setUpdateInterval(10);
84
-
85
- const accelSub = Accelerometer.addListener(({ x, y, z }) => accel.push([x, y, z]));
86
- const gyroSub = Gyroscope.addListener(({ x, y, z }) => gyro.push([x, y, z]));
87
-
88
- await new Promise(r => setTimeout(r, durationMs));
89
-
90
- accelSub.remove();
91
- gyroSub.remove();
92
- } catch {
93
- // expo-sensors not available — return empty arrays
94
- }
95
-
96
- return { accel, gyro };
97
- }
98
-
99
- /* ─── Touch collector ────────────────────────────────────────────────────── */
100
-
101
- /**
102
- * Returns a PanResponder that records touch dwell times and velocities.
103
- * Attach to the root view of the screen being probed.
104
- */
105
- function _createTouchResponder(touchLog) {
106
- try {
107
- const { PanResponder } = require('react-native');
108
- let downAt = 0;
109
-
110
- return PanResponder.create({
111
- onStartShouldSetPanResponder: () => false, // observe only, don't capture
112
- onMoveShouldSetPanResponder: () => false,
113
- onPanResponderGrant: () => { downAt = Date.now(); },
114
- onPanResponderRelease: (_, gs) => {
115
- const dwell = Date.now() - downAt;
116
- touchLog.push({
117
- dwell,
118
- vx: gs.vx,
119
- vy: gs.vy,
120
- dx: gs.dx,
121
- dy: gs.dy,
122
- t: downAt,
123
- });
124
- },
125
- });
126
- } catch {
127
- return null;
128
- }
129
- }
130
-
131
- /* ─── Signal analysis ────────────────────────────────────────────────────── */
132
-
133
- /**
134
- * Analyse accelerometer data for physiological micro-tremor (8–12 Hz).
135
- * Uses a simple DFT over the Z-axis (gravity-compensated).
136
- *
137
- * @param {number[][]} accel array of [x, y, z] samples at ~100 Hz
138
- * @returns {{ tremorPresent: boolean, tremorPower: number, rmsNoise: number, sampleRate: number }}
139
- */
140
- function _analyseTremor(accel) {
141
- if (accel.length < 64) {
142
- return { tremorPresent: false, tremorPower: 0, rmsNoise: 0, sampleRate: 0 };
143
- }
144
-
145
- const n = accel.length;
146
- const sampleRate = 100; // Hz (we set updateInterval to 10ms)
147
-
148
- // Use magnitude (removes orientation dependency)
149
- const mag = accel.map(([x, y, z]) => Math.sqrt(x*x + y*y + z*z));
150
-
151
- // Remove gravity (DC offset) via moving average
152
- const windowSize = Math.round(sampleRate * 0.5); // 0.5s window
153
- const detrended = mag.map((v, i) => {
154
- const lo = Math.max(0, i - windowSize);
155
- const hi = Math.min(n - 1, i + windowSize);
156
- let sum = 0;
157
- for (let j = lo; j <= hi; j++) sum += mag[j];
158
- return v - sum / (hi - lo + 1);
159
- });
160
-
161
- // RMS noise (total signal energy)
162
- const rmsNoise = Math.sqrt(detrended.reduce((s, v) => s + v * v, 0) / n);
163
-
164
- // DFT: look for power in 8–12 Hz band
165
- const loHz = 8, hiHz = 12;
166
- let tremorPower = 0, totalPower = 0;
167
-
168
- for (let k = 1; k < Math.floor(n / 2); k++) {
169
- const freq = k * sampleRate / n;
170
- let re = 0, im = 0;
171
- for (let t = 0; t < n; t++) {
172
- const angle = 2 * Math.PI * k * t / n;
173
- re += detrended[t] * Math.cos(angle);
174
- im -= detrended[t] * Math.sin(angle);
175
- }
176
- const power = (re * re + im * im) / (n * n);
177
- totalPower += power;
178
- if (freq >= loHz && freq <= hiHz) tremorPower += power;
179
- }
180
-
181
- const tremorRatio = totalPower > 0 ? tremorPower / totalPower : 0;
182
- const tremorPresent = tremorRatio > 0.12 && rmsNoise > 0.001;
183
-
184
- return { tremorPresent, tremorPower: +tremorRatio.toFixed(4), rmsNoise: +rmsNoise.toFixed(6), sampleRate };
185
- }
186
-
187
- /**
188
- * Analyse touch events for human vs automated patterns.
189
- * @param {{ dwell: number }[]} touchLog
190
- * @returns {{ humanConf: number, dwellMean: number, dwellCV: number, sampleCount: number }}
191
- */
192
- function _analyseTouches(touchLog) {
193
- if (touchLog.length < 3) {
194
- return { humanConf: 0.5, dwellMean: 0, dwellCV: 0, sampleCount: touchLog.length };
195
- }
196
-
197
- const dwells = touchLog.map(t => t.dwell).filter(d => d > 0 && d < 2000);
198
- if (dwells.length < 2) return { humanConf: 0.5, dwellMean: 0, dwellCV: 0, sampleCount: 0 };
199
-
200
- const mean = dwells.reduce((s, v) => s + v, 0) / dwells.length;
201
- const std = Math.sqrt(dwells.reduce((s, v) => s + (v - mean) ** 2, 0) / dwells.length);
202
- const cv = mean > 0 ? std / mean : 0;
203
-
204
- // Human: mean 80–250ms, CV 0.25–0.65 (log-normal distribution)
205
- // Bot: mean ~0ms or fixed (CV near 0)
206
- let humanConf = 0;
207
- if (mean >= 50 && mean <= 300) humanConf += 0.35;
208
- if (cv >= 0.2 && cv <= 0.7) humanConf += 0.35;
209
- if (dwells.length >= 5) humanConf += 0.20;
210
- if (mean >= 80 && mean <= 200) humanConf += 0.10;
211
-
212
- return { humanConf: Math.min(1, humanConf), dwellMean: +mean.toFixed(1), dwellCV: +cv.toFixed(3), sampleCount: dwells.length };
213
- }
214
-
215
- /**
216
- * Analyse gyroscope for micro-rotation noise.
217
- * @param {number[][]} gyro
218
- * @returns {{ gyroNoise: number, isStatic: boolean }}
219
- */
220
- function _analyseGyro(gyro) {
221
- if (gyro.length < 10) return { gyroNoise: 0, isStatic: true };
222
-
223
- const mags = gyro.map(([x, y, z]) => Math.sqrt(x*x + y*y + z*z));
224
- const mean = mags.reduce((s, v) => s + v, 0) / mags.length;
225
- const rms = Math.sqrt(mags.reduce((s, v) => s + v * v, 0) / mags.length);
226
-
227
- return {
228
- gyroNoise: +rms.toFixed(6),
229
- isStatic: rms < 0.005, // rad/s — emulator threshold
230
- sampleCount: gyro.length,
231
- };
232
- }
233
-
234
- /* ─── usePulseNative ─────────────────────────────────────────────────────── */
235
-
236
- /**
237
- * React Native hook for the Physical Turing Test.
238
- *
239
- * @param {object} opts
240
- * @param {string} [opts.challengeUrl] - GET endpoint that returns { nonce, ...challenge }
241
- * @param {string} [opts.verifyUrl] - POST endpoint that accepts { payload, hash }
242
- * @param {string} [opts.apiKey] - hosted API key (alternative to self-hosted URLs)
243
- * @param {number} [opts.sensorMs=3000] - how long to sample sensors
244
- * @param {boolean} [opts.autoRun=false] - start probe immediately on mount
245
- * @param {Function}[opts.onResult] - callback(trustScore, proof)
246
- * @param {Function}[opts.onError] - callback(error)
247
- *
248
- * @returns {{
249
- * run: () => Promise<void>
250
- * reset: () => void
251
- * isRunning: boolean
252
- * stage: string|null
253
- * pct: number 0–100 progress
254
- * trustScore: TrustScore|null
255
- * tremor: object|null accelerometer analysis
256
- * touches: object|null touch analysis
257
- * proof: object|null { payload, hash }
258
- * error: Error|null
259
- * panHandlers: object|null attach to <View> for touch collection
260
- * }}
261
- */
262
- export function usePulseNative(opts = {}) {
263
- const {
264
- challengeUrl,
265
- verifyUrl,
266
- apiKey,
267
- sensorMs = 3_000,
268
- autoRun = false,
269
- onResult,
270
- onError,
271
- } = opts;
272
-
273
- const [stage, setStage] = useState(null);
274
- const [pct, setPct] = useState(0);
275
- const [isRunning, setIsRunning] = useState(false);
276
- const [trustScore, setTrustScore] = useState(null);
277
- const [tremor, setTremor] = useState(null);
278
- const [touches, setTouches] = useState(null);
279
- const [proof, setProof] = useState(null);
280
- const [error, setError] = useState(null);
281
-
282
- const touchLog = useRef([]);
283
- const panResponder = useRef(null);
284
-
285
- // Initialise touch responder once
286
- useEffect(() => {
287
- panResponder.current = _createTouchResponder(touchLog.current);
288
- }, []);
289
-
290
- const reset = useCallback(() => {
291
- setStage(null); setPct(0); setIsRunning(false);
292
- setTrustScore(null); setTremor(null); setTouches(null);
293
- setProof(null); setError(null);
294
- touchLog.current = [];
295
- }, []);
296
-
297
- const run = useCallback(async () => {
298
- if (isRunning) return;
299
-
300
- reset();
301
- setIsRunning(true);
302
-
303
- try {
304
- const platform = _getPlatform();
305
-
306
- // ── 1. Fetch challenge ──────────────────────────────────────────────
307
- setStage('challenge'); setPct(5);
308
- let nonce, challengeMeta;
309
-
310
- if (apiKey) {
311
- const res = await fetch('https://api.svrnsec.com/v1/challenge', {
312
- headers: { Authorization: `Bearer ${apiKey}` },
313
- });
314
- const body = await res.json();
315
- nonce = body.nonce;
316
- challengeMeta = body;
317
- } else if (challengeUrl) {
318
- const res = await fetch(challengeUrl);
319
- const body = await res.json();
320
- nonce = body.nonce;
321
- challengeMeta = body;
322
- } else {
323
- // Offline self-test — generate a local nonce (no server verification)
324
- const arr = new Uint8Array(32);
325
- crypto.getRandomValues(arr);
326
- nonce = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
327
- }
328
-
329
- // ── 2. Sensor collection (runs in parallel with entropy) ────────────
330
- setStage('sensors'); setPct(15);
331
-
332
- const [sensorData] = await Promise.all([
333
- _collectSensors(sensorMs),
334
- ]);
335
-
336
- setPct(60);
337
-
338
- // ── 3. Analyse sensors ──────────────────────────────────────────────
339
- setStage('analysis'); setPct(70);
340
-
341
- const tremorResult = _analyseTremor(sensorData.accel);
342
- const gyroResult = _analyseGyro(sensorData.gyro);
343
- const touchResult = _analyseTouches(touchLog.current);
344
-
345
- setTremor({ ...tremorResult, ...gyroResult });
346
- setTouches(touchResult);
347
- setPct(80);
348
-
349
- // ── 4. Build mobile proof ───────────────────────────────────────────
350
- setStage('proof'); setPct(85);
351
-
352
- const mobileSignals = {
353
- platform,
354
- tremor: tremorResult,
355
- gyro: gyroResult,
356
- touch: touchResult,
357
- sensorMs,
358
- collectedAt: Date.now(),
359
- };
360
-
361
- // Compute mobile TrustScore from sensor signals
362
- const { computeTrustScore } = await import('../analysis/trustScore.js');
363
-
364
- // Build a synthetic payload for TrustScore computation
365
- const syntheticPayload = {
366
- signals: {
367
- // Encode tremor as a jitter proxy
368
- entropy: {
369
- quantizationEntropy: tremorResult.tremorPresent ? 3.5 : 1.2,
370
- hurstExponent: 0.52,
371
- timingsCV: tremorResult.rmsNoise * 50,
372
- autocorr_lag1: tremorResult.tremorPresent ? 0.05 : 0.45,
373
- },
374
- bio: { hasActivity: touchResult.sampleCount > 0 },
375
- llm: {
376
- aiConf: 1 - touchResult.humanConf,
377
- correctionRate: 0.08, // mobile doesn't have keyboard
378
- rhythmicity: tremorResult.tremorPower,
379
- },
380
- },
381
- classification: {
382
- jitterScore: tremorResult.tremorPresent ? 0.75 : 0.25,
383
- vmIndicators: [
384
- !tremorResult.tremorPresent && sensorData.accel.length > 50 ? 'no_tremor' : null,
385
- gyroResult.isStatic && sensorData.gyro.length > 10 ? 'static_gyro' : null,
386
- ].filter(Boolean),
387
- },
388
- };
389
-
390
- const ts = computeTrustScore(syntheticPayload);
391
- setTrustScore(ts);
392
- setPct(90);
393
-
394
- // ── 5. Build and optionally verify proof ────────────────────────────
395
- setStage('verify'); setPct(95);
396
-
397
- const proofData = {
398
- nonce,
399
- platform,
400
- signals: mobileSignals,
401
- trustScore: ts,
402
- challenge: challengeMeta,
403
- };
404
-
405
- setProof(proofData);
406
-
407
- if (verifyUrl && (challengeUrl || apiKey)) {
408
- const vRes = await fetch(verifyUrl, {
409
- method: 'POST',
410
- headers: {
411
- 'Content-Type': 'application/json',
412
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
413
- },
414
- body: JSON.stringify(proofData),
415
- });
416
- const result = await vRes.json();
417
- proofData.result = result;
418
- setProof(proofData);
419
- }
420
-
421
- setPct(100);
422
- setStage('complete');
423
- onResult?.(ts, proofData);
424
-
425
- } catch (err) {
426
- setError(err);
427
- setStage('error');
428
- onError?.(err);
429
- } finally {
430
- setIsRunning(false);
431
- }
432
- }, [isRunning, apiKey, challengeUrl, verifyUrl, sensorMs, onResult, onError, reset]);
433
-
434
- useEffect(() => {
435
- if (autoRun) run();
436
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
437
-
438
- return {
439
- run,
440
- reset,
441
- isRunning,
442
- stage,
443
- pct,
444
- trustScore,
445
- tremor,
446
- touches,
447
- proof,
448
- error,
449
- // Spread onto your root <View> to collect touch events
450
- panHandlers: panResponder.current?.panHandlers ?? null,
451
- };
452
- }
453
-
454
- /* ─── Named exports for individual signal access ─────────────────────────── */
455
-
456
- export { _analyseTremor as analyseTremor };
457
- export { _analyseTouches as analyseTouches };
458
- export { _analyseGyro as analyseGyro };
459
- export { _collectSensors as collectSensors };
1
+ /**
2
+ * @svrnsec/pulse — React Native Hook
3
+ *
4
+ * Runs the Physical Turing Test on iOS and Android.
5
+ *
6
+ * Mobile adds two signals that desktop cannot provide:
7
+ *
8
+ * 1. Accelerometer micro-tremor (8–12 Hz physiological band)
9
+ * Human hands shake at 8–12 Hz involuntarily. This appears as a
10
+ * continuous low-amplitude signal in the accelerometer. Emulators
11
+ * (Android Emulator, iOS Simulator, any cloud device farm) have
12
+ * zero or perfectly smooth accelerometer output. Real devices on
13
+ * a desk still show ~0.002g RMS noise from building vibration and
14
+ * HDD/fan resonance transmitted through the surface.
15
+ *
16
+ * 2. Touch event temporal fingerprint
17
+ * Human touch-down → touch-up durations follow a log-normal
18
+ * distribution (mean ~120ms, CV ~0.4). Automated taps from
19
+ * Appium, UIAutomator, XCUITest, or an LLM agent are either
20
+ * instantaneous (0ms dwell) or at a fixed scripted duration.
21
+ *
22
+ * 3. Gyroscope micro-rotation
23
+ * Real hands holding a phone produce sub-degree rotation noise
24
+ * at 0–5 Hz. An emulator or a phone in a robot testing rig has
25
+ * near-zero gyroscope variance.
26
+ *
27
+ * Peer dependencies (install separately):
28
+ * expo-sensors (Accelerometer, Gyroscope)
29
+ * react-native (Platform, PanResponder)
30
+ *
31
+ * Both expo-sensors (Expo managed/bare workflow) and react-native are
32
+ * already in every React Native project — this hook adds zero new deps.
33
+ *
34
+ * Usage:
35
+ * import { usePulseNative } from '@svrnsec/pulse/react-native';
36
+ *
37
+ * function CheckoutScreen() {
38
+ * const { run, isRunning, trustScore, verdict } = usePulseNative({
39
+ * challengeUrl: '/api/challenge',
40
+ * verifyUrl: '/api/verify',
41
+ * });
42
+ *
43
+ * useEffect(() => { run(); }, []);
44
+ *
45
+ * if (trustScore) {
46
+ * return <Text>TrustScore: {trustScore.score}/100 ({trustScore.grade})</Text>;
47
+ * }
48
+ * }
49
+ */
50
+
51
+ import { useState, useEffect, useRef, useCallback } from 'react';
52
+
53
+ /* ─── Platform detection ─────────────────────────────────────────────────── */
54
+
55
+ function _getPlatform() {
56
+ try {
57
+ const { Platform } = require('react-native');
58
+ return Platform.OS; // 'ios' | 'android' | 'web'
59
+ } catch {
60
+ return 'unknown';
61
+ }
62
+ }
63
+
64
+ /* ─── Sensor collector ───────────────────────────────────────────────────── */
65
+
66
+ /**
67
+ * Collect accelerometer + gyroscope samples for tremor analysis.
68
+ * Returns a promise that resolves after `durationMs`.
69
+ *
70
+ * @param {number} durationMs
71
+ * @returns {Promise<{ accel: number[][], gyro: number[][] }>}
72
+ * accel[i] = [x, y, z] in g
73
+ * gyro[i] = [x, y, z] in rad/s
74
+ */
75
+ async function _collectSensors(durationMs = 3000) {
76
+ const accel = [];
77
+ const gyro = [];
78
+
79
+ try {
80
+ const { Accelerometer, Gyroscope } = require('expo-sensors');
81
+
82
+ Accelerometer.setUpdateInterval(10); // 100 Hz
83
+ Gyroscope.setUpdateInterval(10);
84
+
85
+ const accelSub = Accelerometer.addListener(({ x, y, z }) => accel.push([x, y, z]));
86
+ const gyroSub = Gyroscope.addListener(({ x, y, z }) => gyro.push([x, y, z]));
87
+
88
+ try {
89
+ await new Promise(r => setTimeout(r, durationMs));
90
+ } finally {
91
+ accelSub.remove();
92
+ gyroSub.remove();
93
+ }
94
+ } catch {
95
+ // expo-sensors not available — return empty arrays
96
+ }
97
+
98
+ return { accel, gyro };
99
+ }
100
+
101
+ /* ─── Touch collector ────────────────────────────────────────────────────── */
102
+
103
+ /**
104
+ * Returns a PanResponder that records touch dwell times and velocities.
105
+ * Attach to the root view of the screen being probed.
106
+ */
107
+ function _createTouchResponder(touchLog) {
108
+ try {
109
+ const { PanResponder } = require('react-native');
110
+ let downAt = 0;
111
+
112
+ return PanResponder.create({
113
+ onStartShouldSetPanResponder: () => false, // observe only, don't capture
114
+ onMoveShouldSetPanResponder: () => false,
115
+ onPanResponderGrant: () => { downAt = Date.now(); },
116
+ onPanResponderRelease: (_, gs) => {
117
+ const dwell = Date.now() - downAt;
118
+ touchLog.push({
119
+ dwell,
120
+ vx: gs.vx,
121
+ vy: gs.vy,
122
+ dx: gs.dx,
123
+ dy: gs.dy,
124
+ t: downAt,
125
+ });
126
+ },
127
+ });
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /* ─── Signal analysis ────────────────────────────────────────────────────── */
134
+
135
+ /**
136
+ * Analyse accelerometer data for physiological micro-tremor (8–12 Hz).
137
+ * Uses a simple DFT over the Z-axis (gravity-compensated).
138
+ *
139
+ * @param {number[][]} accel array of [x, y, z] samples at ~100 Hz
140
+ * @returns {{ tremorPresent: boolean, tremorPower: number, rmsNoise: number, sampleRate: number }}
141
+ */
142
+ function _analyseTremor(accel) {
143
+ if (accel.length < 64) {
144
+ return { tremorPresent: false, tremorPower: 0, rmsNoise: 0, sampleRate: 0 };
145
+ }
146
+
147
+ const n = accel.length;
148
+ const sampleRate = 100; // Hz (we set updateInterval to 10ms)
149
+
150
+ // Use magnitude (removes orientation dependency)
151
+ const mag = accel.map(([x, y, z]) => Math.sqrt(x*x + y*y + z*z));
152
+
153
+ // Remove gravity (DC offset) via moving average
154
+ const windowSize = Math.round(sampleRate * 0.5); // 0.5s window
155
+ const detrended = mag.map((v, i) => {
156
+ const lo = Math.max(0, i - windowSize);
157
+ const hi = Math.min(n - 1, i + windowSize);
158
+ let sum = 0;
159
+ for (let j = lo; j <= hi; j++) sum += mag[j];
160
+ return v - sum / (hi - lo + 1);
161
+ });
162
+
163
+ // RMS noise (total signal energy)
164
+ const rmsNoise = Math.sqrt(detrended.reduce((s, v) => s + v * v, 0) / n);
165
+
166
+ // DFT: look for power in 8–12 Hz band
167
+ const loHz = 8, hiHz = 12;
168
+ let tremorPower = 0, totalPower = 0;
169
+
170
+ for (let k = 1; k < Math.floor(n / 2); k++) {
171
+ const freq = k * sampleRate / n;
172
+ let re = 0, im = 0;
173
+ for (let t = 0; t < n; t++) {
174
+ const angle = 2 * Math.PI * k * t / n;
175
+ re += detrended[t] * Math.cos(angle);
176
+ im -= detrended[t] * Math.sin(angle);
177
+ }
178
+ const power = (re * re + im * im) / (n * n);
179
+ totalPower += power;
180
+ if (freq >= loHz && freq <= hiHz) tremorPower += power;
181
+ }
182
+
183
+ const tremorRatio = totalPower > 0 ? tremorPower / totalPower : 0;
184
+ const tremorPresent = tremorRatio > 0.12 && rmsNoise > 0.001;
185
+
186
+ return { tremorPresent, tremorPower: +tremorRatio.toFixed(4), rmsNoise: +rmsNoise.toFixed(6), sampleRate };
187
+ }
188
+
189
+ /**
190
+ * Analyse touch events for human vs automated patterns.
191
+ * @param {{ dwell: number }[]} touchLog
192
+ * @returns {{ humanConf: number, dwellMean: number, dwellCV: number, sampleCount: number }}
193
+ */
194
+ function _analyseTouches(touchLog) {
195
+ if (touchLog.length < 3) {
196
+ return { humanConf: 0.5, dwellMean: 0, dwellCV: 0, sampleCount: touchLog.length };
197
+ }
198
+
199
+ const dwells = touchLog.map(t => t.dwell).filter(d => d > 0 && d < 2000);
200
+ if (dwells.length < 2) return { humanConf: 0.5, dwellMean: 0, dwellCV: 0, sampleCount: 0 };
201
+
202
+ const mean = dwells.reduce((s, v) => s + v, 0) / dwells.length;
203
+ const std = Math.sqrt(dwells.reduce((s, v) => s + (v - mean) ** 2, 0) / dwells.length);
204
+ const cv = mean > 0 ? std / mean : 0;
205
+
206
+ // Human: mean 80–250ms, CV 0.25–0.65 (log-normal distribution)
207
+ // Bot: mean ~0ms or fixed (CV near 0)
208
+ let humanConf = 0;
209
+ if (mean >= 50 && mean <= 300) humanConf += 0.35;
210
+ if (cv >= 0.2 && cv <= 0.7) humanConf += 0.35;
211
+ if (dwells.length >= 5) humanConf += 0.20;
212
+ if (mean >= 80 && mean <= 200) humanConf += 0.10;
213
+
214
+ return { humanConf: Math.min(1, humanConf), dwellMean: +mean.toFixed(1), dwellCV: +cv.toFixed(3), sampleCount: dwells.length };
215
+ }
216
+
217
+ /**
218
+ * Analyse gyroscope for micro-rotation noise.
219
+ * @param {number[][]} gyro
220
+ * @returns {{ gyroNoise: number, isStatic: boolean }}
221
+ */
222
+ function _analyseGyro(gyro) {
223
+ if (gyro.length < 10) return { gyroNoise: 0, isStatic: true };
224
+
225
+ const mags = gyro.map(([x, y, z]) => Math.sqrt(x*x + y*y + z*z));
226
+ const mean = mags.reduce((s, v) => s + v, 0) / mags.length;
227
+ const rms = Math.sqrt(mags.reduce((s, v) => s + v * v, 0) / mags.length);
228
+
229
+ return {
230
+ gyroNoise: +rms.toFixed(6),
231
+ isStatic: rms < 0.005, // rad/s — emulator threshold
232
+ sampleCount: gyro.length,
233
+ };
234
+ }
235
+
236
+ /* ─── usePulseNative ─────────────────────────────────────────────────────── */
237
+
238
+ /**
239
+ * React Native hook for the Physical Turing Test.
240
+ *
241
+ * @param {object} opts
242
+ * @param {string} [opts.challengeUrl] - GET endpoint that returns { nonce, ...challenge }
243
+ * @param {string} [opts.verifyUrl] - POST endpoint that accepts { payload, hash }
244
+ * @param {string} [opts.apiKey] - hosted API key (alternative to self-hosted URLs)
245
+ * @param {number} [opts.sensorMs=3000] - how long to sample sensors
246
+ * @param {boolean} [opts.autoRun=false] - start probe immediately on mount
247
+ * @param {Function}[opts.onResult] - callback(trustScore, proof)
248
+ * @param {Function}[opts.onError] - callback(error)
249
+ *
250
+ * @returns {{
251
+ * run: () => Promise<void>
252
+ * reset: () => void
253
+ * isRunning: boolean
254
+ * stage: string|null
255
+ * pct: number 0–100 progress
256
+ * trustScore: TrustScore|null
257
+ * tremor: object|null accelerometer analysis
258
+ * touches: object|null touch analysis
259
+ * proof: object|null { payload, hash }
260
+ * error: Error|null
261
+ * panHandlers: object|null attach to <View> for touch collection
262
+ * }}
263
+ */
264
+ export function usePulseNative(opts = {}) {
265
+ const {
266
+ challengeUrl,
267
+ verifyUrl,
268
+ apiKey,
269
+ sensorMs = 3_000,
270
+ autoRun = false,
271
+ onResult,
272
+ onError,
273
+ } = opts;
274
+
275
+ const [stage, setStage] = useState(null);
276
+ const [pct, setPct] = useState(0);
277
+ const [isRunning, setIsRunning] = useState(false);
278
+ const [trustScore, setTrustScore] = useState(null);
279
+ const [tremor, setTremor] = useState(null);
280
+ const [touches, setTouches] = useState(null);
281
+ const [proof, setProof] = useState(null);
282
+ const [error, setError] = useState(null);
283
+
284
+ const touchLog = useRef([]);
285
+ const panResponder = useRef(null);
286
+
287
+ // Initialise touch responder once
288
+ useEffect(() => {
289
+ panResponder.current = _createTouchResponder(touchLog.current);
290
+ }, []);
291
+
292
+ const reset = useCallback(() => {
293
+ setStage(null); setPct(0); setIsRunning(false);
294
+ setTrustScore(null); setTremor(null); setTouches(null);
295
+ setProof(null); setError(null);
296
+ touchLog.current = [];
297
+ }, []);
298
+
299
+ const run = useCallback(async () => {
300
+ if (isRunning) return;
301
+
302
+ reset();
303
+ setIsRunning(true);
304
+
305
+ try {
306
+ const platform = _getPlatform();
307
+
308
+ // ── 1. Fetch challenge ──────────────────────────────────────────────
309
+ setStage('challenge'); setPct(5);
310
+ let nonce, challengeMeta;
311
+
312
+ if (apiKey) {
313
+ const res = await fetch('https://api.svrnsec.com/v1/challenge', {
314
+ headers: { Authorization: `Bearer ${apiKey}` },
315
+ });
316
+ const body = await res.json();
317
+ nonce = body.nonce;
318
+ challengeMeta = body;
319
+ } else if (challengeUrl) {
320
+ const res = await fetch(challengeUrl);
321
+ const body = await res.json();
322
+ nonce = body.nonce;
323
+ challengeMeta = body;
324
+ } else {
325
+ // Offline self-test — generate a local nonce (no server verification)
326
+ const arr = new Uint8Array(32);
327
+ crypto.getRandomValues(arr);
328
+ nonce = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
329
+ }
330
+
331
+ // ── 2. Sensor collection (runs in parallel with entropy) ────────────
332
+ setStage('sensors'); setPct(15);
333
+
334
+ const [sensorData] = await Promise.all([
335
+ _collectSensors(sensorMs),
336
+ ]);
337
+
338
+ setPct(60);
339
+
340
+ // ── 3. Analyse sensors ──────────────────────────────────────────────
341
+ setStage('analysis'); setPct(70);
342
+
343
+ const tremorResult = _analyseTremor(sensorData.accel);
344
+ const gyroResult = _analyseGyro(sensorData.gyro);
345
+ const touchResult = _analyseTouches(touchLog.current);
346
+
347
+ setTremor({ ...tremorResult, ...gyroResult });
348
+ setTouches(touchResult);
349
+ setPct(80);
350
+
351
+ // ── 4. Build mobile proof ───────────────────────────────────────────
352
+ setStage('proof'); setPct(85);
353
+
354
+ const mobileSignals = {
355
+ platform,
356
+ tremor: tremorResult,
357
+ gyro: gyroResult,
358
+ touch: touchResult,
359
+ sensorMs,
360
+ collectedAt: Date.now(),
361
+ };
362
+
363
+ // Compute mobile TrustScore from sensor signals
364
+ const { computeTrustScore } = await import('../analysis/trustScore.js');
365
+
366
+ // Build a synthetic payload for TrustScore computation
367
+ const syntheticPayload = {
368
+ signals: {
369
+ // Encode tremor as a jitter proxy
370
+ entropy: {
371
+ quantizationEntropy: tremorResult.tremorPresent ? 3.5 : 1.2,
372
+ hurstExponent: 0.52,
373
+ timingsCV: tremorResult.rmsNoise * 50,
374
+ autocorr_lag1: tremorResult.tremorPresent ? 0.05 : 0.45,
375
+ },
376
+ bio: { hasActivity: touchResult.sampleCount > 0 },
377
+ llm: {
378
+ aiConf: 1 - touchResult.humanConf,
379
+ correctionRate: 0.08, // mobile doesn't have keyboard
380
+ rhythmicity: tremorResult.tremorPower,
381
+ },
382
+ },
383
+ classification: {
384
+ jitterScore: tremorResult.tremorPresent ? 0.75 : 0.25,
385
+ vmIndicators: [
386
+ !tremorResult.tremorPresent && sensorData.accel.length > 50 ? 'no_tremor' : null,
387
+ gyroResult.isStatic && sensorData.gyro.length > 10 ? 'static_gyro' : null,
388
+ ].filter(Boolean),
389
+ },
390
+ };
391
+
392
+ const ts = computeTrustScore(syntheticPayload);
393
+ setTrustScore(ts);
394
+ setPct(90);
395
+
396
+ // ── 5. Build and optionally verify proof ────────────────────────────
397
+ setStage('verify'); setPct(95);
398
+
399
+ const proofData = {
400
+ nonce,
401
+ platform,
402
+ signals: mobileSignals,
403
+ trustScore: ts,
404
+ challenge: challengeMeta,
405
+ };
406
+
407
+ setProof(proofData);
408
+
409
+ if (verifyUrl && (challengeUrl || apiKey)) {
410
+ const vRes = await fetch(verifyUrl, {
411
+ method: 'POST',
412
+ headers: {
413
+ 'Content-Type': 'application/json',
414
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
415
+ },
416
+ body: JSON.stringify(proofData),
417
+ });
418
+ if (!vRes.ok) throw new Error('Verify failed: ' + vRes.status);
419
+ const result = await vRes.json();
420
+ proofData.result = result;
421
+ setProof(proofData);
422
+ }
423
+
424
+ setPct(100);
425
+ setStage('complete');
426
+ onResult?.(ts, proofData);
427
+
428
+ } catch (err) {
429
+ setError(err);
430
+ setStage('error');
431
+ onError?.(err);
432
+ } finally {
433
+ setIsRunning(false);
434
+ }
435
+ }, [isRunning, apiKey, challengeUrl, verifyUrl, sensorMs, onResult, onError, reset]);
436
+
437
+ useEffect(() => {
438
+ if (autoRun) run();
439
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
440
+
441
+ return {
442
+ run,
443
+ reset,
444
+ isRunning,
445
+ stage,
446
+ pct,
447
+ trustScore,
448
+ tremor,
449
+ touches,
450
+ proof,
451
+ error,
452
+ // Spread onto your root <View> to collect touch events
453
+ panHandlers: panResponder.current?.panHandlers ?? null,
454
+ };
455
+ }
456
+
457
+ /* ─── Named exports for individual signal access ─────────────────────────── */
458
+
459
+ export { _analyseTremor as analyseTremor };
460
+ export { _analyseTouches as analyseTouches };
461
+ export { _analyseGyro as analyseGyro };
462
+ export { _collectSensors as collectSensors };