@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
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
*
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* @param {
|
|
242
|
-
* @param {string} [opts.
|
|
243
|
-
* @param {
|
|
244
|
-
* @param {
|
|
245
|
-
* @param {
|
|
246
|
-
* @param {
|
|
247
|
-
*
|
|
248
|
-
* @
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const [
|
|
276
|
-
const [
|
|
277
|
-
const [
|
|
278
|
-
const [
|
|
279
|
-
const [
|
|
280
|
-
const [
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
]
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
export {
|
|
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 };
|