@svrnsec/pulse 0.3.1 → 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/bin/svrnsec-pulse.js +7 -0
- package/index.d.ts +130 -0
- package/package.json +51 -24
- package/src/analysis/audio.js +213 -0
- package/src/analysis/coherence.js +502 -0
- package/src/analysis/heuristic.js +428 -0
- package/src/analysis/jitter.js +446 -0
- package/src/analysis/llm.js +472 -0
- package/src/analysis/provider.js +248 -0
- package/src/analysis/trustScore.js +331 -0
- package/src/cli/args.js +36 -0
- package/src/cli/commands/scan.js +192 -0
- package/src/cli/runner.js +157 -0
- package/src/collector/adaptive.js +200 -0
- package/src/collector/bio.js +287 -0
- package/src/collector/canvas.js +239 -0
- package/src/collector/dram.js +203 -0
- package/src/collector/enf.js +311 -0
- package/src/collector/entropy.js +195 -0
- package/src/collector/gpu.js +245 -0
- package/src/collector/sabTimer.js +191 -0
- package/src/fingerprint.js +475 -0
- package/src/index.js +342 -0
- package/src/integrations/react-native.js +459 -0
- package/src/proof/challenge.js +249 -0
- package/src/terminal.js +263 -0
- package/src/update-notifier.js +264 -0
|
@@ -0,0 +1,459 @@
|
|
|
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 };
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — HMAC-Signed Challenge Protocol
|
|
3
|
+
*
|
|
4
|
+
* Hardens the nonce system against three attack vectors that a plain random
|
|
5
|
+
* nonce does not prevent:
|
|
6
|
+
*
|
|
7
|
+
* 1. Forged challenges
|
|
8
|
+
* Without a server secret, an attacker can generate their own nonces,
|
|
9
|
+
* pre-compute a fake proof, and submit it. HMAC signing ties the nonce
|
|
10
|
+
* to the server's secret — a nonce not signed by the server is rejected
|
|
11
|
+
* before the proof is even validated.
|
|
12
|
+
*
|
|
13
|
+
* 2. Replayed challenges
|
|
14
|
+
* A valid nonce captured from a legitimate session can be replayed with
|
|
15
|
+
* a cached proof. The challenge includes an expiry timestamp in the HMAC
|
|
16
|
+
* input — expired challenges are rejected even if the signature is valid.
|
|
17
|
+
*
|
|
18
|
+
* 3. Timestamp manipulation
|
|
19
|
+
* An attacker who intercepts a challenge cannot extend its validity by
|
|
20
|
+
* altering the expiry field because the timestamp is part of the HMAC
|
|
21
|
+
* input. Any modification breaks the signature.
|
|
22
|
+
*
|
|
23
|
+
* Wire format
|
|
24
|
+
* ───────────
|
|
25
|
+
* {
|
|
26
|
+
* nonce: "64-char hex" — random, server-generated
|
|
27
|
+
* issuedAt: 1711234567890 — Unix ms
|
|
28
|
+
* expiresAt: 1711234867890 — issuedAt + ttlMs
|
|
29
|
+
* sig: "64-char hex" — HMAC-SHA256(body, secret)
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* body = `${nonce}|${issuedAt}|${expiresAt}`
|
|
33
|
+
*
|
|
34
|
+
* Usage (server)
|
|
35
|
+
* ──────────────
|
|
36
|
+
* import { createChallenge, verifyChallenge } from '@svrnsec/pulse/challenge';
|
|
37
|
+
*
|
|
38
|
+
* // Challenge endpoint
|
|
39
|
+
* app.get('/api/challenge', (req, res) => {
|
|
40
|
+
* const challenge = createChallenge(process.env.PULSE_SECRET);
|
|
41
|
+
* await redis.set(`pulse:${challenge.nonce}`, '1', 'EX', 300);
|
|
42
|
+
* res.json(challenge);
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // Verify endpoint
|
|
46
|
+
* app.post('/api/verify', async (req, res) => {
|
|
47
|
+
* const { payload, hash } = req.body;
|
|
48
|
+
* const challenge = { nonce: payload.nonce, ...req.body.challenge };
|
|
49
|
+
*
|
|
50
|
+
* const { valid, reason } = verifyChallenge(challenge, process.env.PULSE_SECRET, {
|
|
51
|
+
* checkNonce: async (n) => {
|
|
52
|
+
* const ok = await redis.del(`pulse:${n}`);
|
|
53
|
+
* return ok === 1; // consume on first use
|
|
54
|
+
* },
|
|
55
|
+
* });
|
|
56
|
+
* if (!valid) return res.status(400).json({ error: reason });
|
|
57
|
+
*
|
|
58
|
+
* const result = await validateProof(payload, hash);
|
|
59
|
+
* res.json(result);
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* Zero dependencies: uses Node.js built-in crypto module only.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Constants
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
72
|
+
const NONCE_BYTES = 32; // 256 bits → 64-char hex
|
|
73
|
+
const SIG_ALGORITHM = 'sha256';
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// createChallenge
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Issue a new signed challenge. Call this in your GET /challenge endpoint.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} secret - your server secret (min 32 chars recommended)
|
|
83
|
+
* @param {object} [opts]
|
|
84
|
+
* @param {number} [opts.ttlMs=300000] - challenge validity window (ms)
|
|
85
|
+
* @param {string} [opts.nonce] - override nonce (testing only)
|
|
86
|
+
* @returns {SignedChallenge}
|
|
87
|
+
*/
|
|
88
|
+
export function createChallenge(secret, opts = {}) {
|
|
89
|
+
_assertSecret(secret);
|
|
90
|
+
|
|
91
|
+
const { ttlMs = DEFAULT_TTL_MS } = opts;
|
|
92
|
+
const nonce = opts.nonce ?? randomBytes(NONCE_BYTES).toString('hex');
|
|
93
|
+
const issuedAt = Date.now();
|
|
94
|
+
const expiresAt = issuedAt + ttlMs;
|
|
95
|
+
const sig = _sign(nonce, issuedAt, expiresAt, secret);
|
|
96
|
+
|
|
97
|
+
return { nonce, issuedAt, expiresAt, sig };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// verifyChallenge
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verify an inbound challenge before processing the proof.
|
|
106
|
+
* Call this at the start of your POST /verify endpoint.
|
|
107
|
+
*
|
|
108
|
+
* @param {SignedChallenge} challenge
|
|
109
|
+
* @param {string} secret
|
|
110
|
+
* @param {object} [opts]
|
|
111
|
+
* @param {Function} [opts.checkNonce] async (nonce: string) => boolean
|
|
112
|
+
* Return true if the nonce is valid and consume it.
|
|
113
|
+
* Must be atomic (redis DEL returning 1, DB transaction, etc.)
|
|
114
|
+
* @returns {Promise<{ valid: boolean, reason?: string }>}
|
|
115
|
+
*/
|
|
116
|
+
export async function verifyChallenge(challenge, secret, opts = {}) {
|
|
117
|
+
_assertSecret(secret);
|
|
118
|
+
|
|
119
|
+
const { nonce, issuedAt, expiresAt, sig } = challenge ?? {};
|
|
120
|
+
|
|
121
|
+
// ── Structural checks ──────────────────────────────────────────────────────
|
|
122
|
+
if (!nonce || typeof nonce !== 'string' || !/^[0-9a-f]{64}$/i.test(nonce)) {
|
|
123
|
+
return { valid: false, reason: 'invalid_nonce_format' };
|
|
124
|
+
}
|
|
125
|
+
if (!issuedAt || !expiresAt || !sig) {
|
|
126
|
+
return { valid: false, reason: 'missing_challenge_fields' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Timestamp freshness ────────────────────────────────────────────────────
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (now > expiresAt) {
|
|
132
|
+
return { valid: false, reason: 'challenge_expired' };
|
|
133
|
+
}
|
|
134
|
+
if (issuedAt > now + 30_000) {
|
|
135
|
+
// Clock skew tolerance: reject challenges issued >30s in the future
|
|
136
|
+
return { valid: false, reason: 'challenge_issued_in_future' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── HMAC signature verification (timing-safe) ──────────────────────────────
|
|
140
|
+
const expected = _sign(nonce, issuedAt, expiresAt, secret);
|
|
141
|
+
try {
|
|
142
|
+
const a = Buffer.from(expected, 'hex');
|
|
143
|
+
const b = Buffer.from(sig, 'hex');
|
|
144
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
145
|
+
return { valid: false, reason: 'invalid_signature' };
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
return { valid: false, reason: 'invalid_signature' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Nonce consumption (replay prevention) ──────────────────────────────────
|
|
152
|
+
if (typeof opts.checkNonce === 'function') {
|
|
153
|
+
let consumed;
|
|
154
|
+
try {
|
|
155
|
+
consumed = await opts.checkNonce(nonce);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return { valid: false, reason: 'nonce_check_error' };
|
|
158
|
+
}
|
|
159
|
+
if (!consumed) {
|
|
160
|
+
return { valid: false, reason: 'nonce_already_used_or_unknown' };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { valid: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// embedChallenge / extractChallenge
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Embed a signed challenge inside a ProofPayload's nonce field.
|
|
173
|
+
* The proof's nonce is set to `challenge.nonce`; the full challenge object is
|
|
174
|
+
* included as `challenge.meta` for server-side re-verification.
|
|
175
|
+
*
|
|
176
|
+
* This lets a single API call carry both the nonce for BLAKE3 commitment AND
|
|
177
|
+
* the full signed challenge for server authentication.
|
|
178
|
+
*
|
|
179
|
+
* @param {SignedChallenge} challenge
|
|
180
|
+
* @param {object} payload - ProofPayload (mutates in place)
|
|
181
|
+
* @returns {object} the mutated payload
|
|
182
|
+
*/
|
|
183
|
+
export function embedChallenge(challenge, payload) {
|
|
184
|
+
if (payload.nonce !== challenge.nonce) {
|
|
185
|
+
throw new Error('@svrnsec/pulse: proof nonce does not match challenge nonce');
|
|
186
|
+
}
|
|
187
|
+
payload._challenge = {
|
|
188
|
+
issuedAt: challenge.issuedAt,
|
|
189
|
+
expiresAt: challenge.expiresAt,
|
|
190
|
+
sig: challenge.sig,
|
|
191
|
+
};
|
|
192
|
+
return payload;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Extract a SignedChallenge from a ProofPayload that had embedChallenge() applied.
|
|
197
|
+
* @param {object} payload
|
|
198
|
+
* @returns {SignedChallenge}
|
|
199
|
+
*/
|
|
200
|
+
export function extractChallenge(payload) {
|
|
201
|
+
const meta = payload?._challenge;
|
|
202
|
+
if (!meta) throw new Error('@svrnsec/pulse: no embedded challenge in payload');
|
|
203
|
+
return {
|
|
204
|
+
nonce: payload.nonce,
|
|
205
|
+
issuedAt: meta.issuedAt,
|
|
206
|
+
expiresAt: meta.expiresAt,
|
|
207
|
+
sig: meta.sig,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// generateSecret
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Generate a cryptographically secure server secret.
|
|
217
|
+
* Run once and store in your environment variables.
|
|
218
|
+
*
|
|
219
|
+
* @returns {string} 64-char hex string (256 bits)
|
|
220
|
+
*/
|
|
221
|
+
export function generateSecret() {
|
|
222
|
+
return randomBytes(32).toString('hex');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Internal helpers
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function _sign(nonce, issuedAt, expiresAt, secret) {
|
|
230
|
+
const body = `${nonce}|${issuedAt}|${expiresAt}`;
|
|
231
|
+
return createHmac(SIG_ALGORITHM, secret).update(body).digest('hex');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _assertSecret(secret) {
|
|
235
|
+
if (!secret || typeof secret !== 'string' || secret.length < 16) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
'@svrnsec/pulse: secret must be a string of at least 16 characters. ' +
|
|
238
|
+
'Generate one with: import { generateSecret } from "@svrnsec/pulse/challenge"'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @typedef {object} SignedChallenge
|
|
245
|
+
* @property {string} nonce 64-char hex nonce
|
|
246
|
+
* @property {number} issuedAt Unix ms timestamp
|
|
247
|
+
* @property {number} expiresAt Unix ms expiry
|
|
248
|
+
* @property {string} sig HMAC-SHA256 hex signature
|
|
249
|
+
*/
|