@svrnsec/pulse 0.3.1 → 0.5.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.
@@ -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
+ */