@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.
Files changed (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +883 -622
  3. package/SECURITY.md +86 -86
  4. package/bin/svrnsec-pulse.js +7 -7
  5. package/dist/{pulse.cjs.js → pulse.cjs} +6379 -6420
  6. package/dist/pulse.cjs.map +1 -0
  7. package/dist/pulse.esm.js +6380 -6421
  8. package/dist/pulse.esm.js.map +1 -1
  9. package/index.d.ts +895 -846
  10. package/package.json +185 -165
  11. package/pkg/pulse_core.js +174 -173
  12. package/src/analysis/audio.js +213 -213
  13. package/src/analysis/authenticityAudit.js +408 -390
  14. package/src/analysis/coherence.js +502 -502
  15. package/src/analysis/coordinatedBehavior.js +825 -0
  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 -0
  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 +83 -143
  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,394 +1,426 @@
1
- /**
2
- * @svrnsec/pulse — Engagement Token
3
- *
4
- * A short-lived, physics-backed cryptographic token that proves a specific
5
- * engagement event (click, view, share, purchase) originated from a real
6
- * human on real hardware that had genuinely rested between interactions.
7
- *
8
- * This is the layer that defeats the "1,000 phones in a warehouse" attack.
9
- * Each token proves:
10
- *
11
- * 1. Real hardware DRAM refresh present, ENF grid signal detected
12
- * 2. Genuine idle Hash-chained thermal measurements spanning ≥ 45s
13
- * 3. Physical cooling Variance decay was smooth, not a step function
14
- * 4. Fresh interaction 30-second TTL eliminates token brokers
15
- * 5. Tamper-evident HMAC-SHA256 over all fraud-relevant fields
16
- *
17
- * Token wire format (compact: base64url JSON, ~400 bytes)
18
- * ────────────────────────────────────────────────────────
19
- * {
20
- * v: 2, protocol version
21
- * n: "hex64", nonce — 256-bit random
22
- * iat: 1234567890123, issued-at Unix ms
23
- * exp: 1234567920123, expires-at (iat + 30s)
24
- * idle: {
25
- * chain: "hex64", final hash of idle measurement chain
26
- * s: 3, sample count (≥ 2 for a valid proof)
27
- * dMs: 180000, idle duration ms
28
- * therm: "hot_to_cold", thermal transition label
29
- * mono: 0.67, cooling monotonicity (0–1)
30
- * },
31
- * hw: {
32
- * dram: "dram", DRAM probe verdict
33
- * enf: "grid_60hz", ENF probe verdict
34
- * ent: 0.73, normalized physics entropy score (0–1)
35
- * },
36
- * evt: {
37
- * t: "click", event type
38
- * ts: 1234567890123, event Unix ms
39
- * mot: 0.82, motor consistency (0–1)
40
- * },
41
- * sig: "hex64" HMAC-SHA256 over all fraud-relevant fields
42
- * }
43
- *
44
- * HMAC input (pipe-delimited, all fields that matter for fraud)
45
- * ─────────────────────────────────────────────────────────────
46
- * `v|n|iat|exp|idle.chain|idle.dMs|hw.ent|evt.t|evt.ts`
47
- *
48
- * Changing any signed field invalidates the token. Unsigned fields
49
- * (therm, mono, dram, enf) are advisory — they inform risk scoring but
50
- * cannot be manipulated for credit fraud without breaking the HMAC.
51
- */
52
-
53
- import { hmac } from '@noble/hashes/hmac';
54
- import { sha256 } from '@noble/hashes/sha256';
55
- import { bytesToHex,
56
- utf8ToBytes,
57
- randomBytes } from '@noble/hashes/utils';
58
-
59
- // ── Constants ─────────────────────────────────────────────────────────────────
60
-
61
- const TOKEN_VERSION = 2;
62
-
63
- /** 30-second TTL: short enough to prevent token brokers (resellers of valid
64
- * tokens scraped from legitimate devices), long enough to survive one API
65
- * round-trip on a slow connection. */
66
- const TOKEN_TTL_MS = 30_000;
67
-
68
- const NONCE_BYTES = 32; // 256-bit nonce → 64-char hex
69
-
70
- // ── createEngagementToken ─────────────────────────────────────────────────────
71
-
72
- /**
73
- * Create a physics-backed engagement token.
74
- *
75
- * Attach the returned `compact` string to your API call:
76
- * `fetch('/api/action', { headers: { 'X-Pulse-Token': token.compact } })`
77
- *
78
- * @param {object} opts
79
- * @param {object} opts.pulseResult result object from pulse()
80
- * @param {IdleProof|null} opts.idleProof from idleMonitor.getProof()
81
- * @param {object} opts.interaction { type, ts, motorConsistency }
82
- * @param {string} opts.secret shared HMAC secret (min 16 chars)
83
- * @param {object} [opts._overrides] for deterministic testing only
84
- * @returns {EngagementToken}
85
- */
86
- export function createEngagementToken(opts) {
87
- const {
88
- pulseResult = {},
89
- idleProof = null,
90
- interaction = {},
91
- secret,
92
- _overrides = {},
93
- } = opts;
94
-
95
- _assertSecret(secret);
96
-
97
- const n = _overrides.nonce ?? bytesToHex(randomBytes(NONCE_BYTES));
98
- const iat = _overrides.issuedAt ?? Date.now();
99
- const exp = iat + TOKEN_TTL_MS;
100
-
101
- // ── Extract hardware evidence from pulse result ───────────────────────────
102
- const extended = pulseResult.extended ?? {};
103
- const dram = extended.dram?.verdict ?? pulseResult.dram?.verdict ?? 'unavailable';
104
- const enf = extended.enf?.verdict ?? pulseResult.enf?.verdict ?? 'unavailable';
105
- const ent = _extractEntropyScore(pulseResult);
106
-
107
- // ENF deviation for population-level phase coherence test
108
- const enfDev = extended.enf?.enfDeviation
109
- ?? pulseResult.enf?.enfDeviation
110
- ?? null;
111
-
112
- // ── Pack idle evidence ────────────────────────────────────────────────────
113
- const idle = idleProof
114
- ? {
115
- chain: idleProof.chain,
116
- s: idleProof.samples,
117
- dMs: idleProof.idleDurationMs,
118
- therm: idleProof.thermalTransition,
119
- mono: idleProof.coolingMonotonicity,
120
- }
121
- : null;
122
-
123
- // ── Pack interaction evidence ─────────────────────────────────────────────
124
- const evt = {
125
- t: interaction.type ?? 'unknown',
126
- ts: interaction.ts ?? iat,
127
- mot: +(interaction.motorConsistency ?? 0).toFixed(3),
128
- };
129
-
130
- // ── Sign and seal ─────────────────────────────────────────────────────────
131
- const hw = { dram, enf, ent, ...(enfDev != null && { enfDev }) };
132
- const unsigned = { v: TOKEN_VERSION, n, iat, exp, idle, hw, evt };
133
- const sig = _sign(unsigned, secret);
134
- const token = { ...unsigned, sig };
135
-
136
- return {
137
- token,
138
- compact: _encode(token),
139
- expiresAt: exp,
140
- };
141
- }
142
-
143
- // ── verifyEngagementToken ─────────────────────────────────────────────────────
144
-
145
- /**
146
- * Verify an engagement token on the server.
147
- *
148
- * Call this in your API handler before crediting any engagement metric.
149
- * Failed verification returns `{ valid: false, reason }` — never throws.
150
- *
151
- * @param {string|object} tokenOrCompact compact base64url string or parsed token
152
- * @param {string} secret shared HMAC secret
153
- * @param {object} [opts]
154
- * @param {Function} [opts.checkNonce] async (nonce: string) => boolean
155
- * Must atomically consume the nonce (Redis DEL returning 1,
156
- * DB transaction with SELECT FOR UPDATE, etc.)
157
- * @param {Function} [opts.now] override Date.now for testing: () => number
158
- * @returns {Promise<EngagementVerifyResult>}
159
- */
160
- export async function verifyEngagementToken(tokenOrCompact, secret, opts = {}) {
161
- _assertSecret(secret);
162
-
163
- // ── Parse ─────────────────────────────────────────────────────────────────
164
- let token;
165
- try {
166
- token = typeof tokenOrCompact === 'string'
167
- ? _decode(tokenOrCompact)
168
- : tokenOrCompact;
169
- } catch {
170
- return _reject('malformed_token');
171
- }
172
-
173
- const { v, n, iat, exp, idle, hw, evt, sig } = token ?? {};
174
-
175
- // ── Structural integrity ──────────────────────────────────────────────────
176
- if (v !== TOKEN_VERSION) return _reject('unsupported_version');
177
- if (!n || !/^[0-9a-f]{64}$/i.test(n)) return _reject('invalid_nonce');
178
- if (!Number.isFinite(iat) || !Number.isFinite(exp) || !sig) {
179
- return _reject('missing_required_fields');
180
- }
181
-
182
- // ── Freshness ─────────────────────────────────────────────────────────────
183
- const now = (opts.now ?? Date.now)();
184
- if (now > exp) return _reject('token_expired', { expiredByMs: now - exp });
185
- if (iat > now + 5_000) return _reject('token_from_future');
186
-
187
- // ── Signature verification (timing-safe comparison) ───────────────────────
188
- const expected = _sign(token, secret);
189
- if (!_timingSafeEqual(expected, sig)) {
190
- return _reject('invalid_signature');
191
- }
192
-
193
- // ── Nonce consumption (replay prevention) ─────────────────────────────────
194
- if (typeof opts.checkNonce === 'function') {
195
- let consumed;
196
- try { consumed = await opts.checkNonce(n); }
197
- catch { return _reject('nonce_check_error'); }
198
- if (!consumed) return _reject('nonce_replayed');
199
- }
200
-
201
- // ── Advisory analysis (non-blocking) ─────────────────────────────────────
202
- const idleWarnings = idle ? _checkIdlePlausibility(idle) : ['no_idle_proof'];
203
- const riskSignals = _assessRisk(hw, idle, evt);
204
-
205
- return {
206
- valid: true,
207
- token,
208
- idleWarnings,
209
- riskSignals,
210
- issuedAt: iat,
211
- expiresAt: exp,
212
- };
213
- }
214
-
215
- // ── Encode / decode ───────────────────────────────────────────────────────────
216
-
217
- /**
218
- * Encode a token object to a compact base64url string.
219
- * @param {object} token
220
- * @returns {string}
221
- */
222
- export function encodeToken(token) {
223
- return _encode(token);
224
- }
225
-
226
- /**
227
- * Decode a compact string without verifying the signature.
228
- * Safe for logging/debugging; use verifyEngagementToken for security checks.
229
- * @param {string} compact
230
- * @returns {object}
231
- */
232
- export function decodeToken(compact) {
233
- return _decode(compact);
234
- }
235
-
236
- // ── Risk assessment ───────────────────────────────────────────────────────────
237
-
238
- /**
239
- * Advisory risk signals: concerns that don't outright invalidate the token
240
- * but should inform downstream risk decisions.
241
- *
242
- * Returned in the `riskSignals` array of a successful verify result.
243
- * Each entry: `{ code: string, severity: 'high'|'medium'|'low' }`.
244
- */
245
- function _assessRisk(hw, idle, evt) {
246
- const signals = [];
247
-
248
- // Hardware layer
249
- if (hw?.dram === 'virtual') signals.push({ code: 'DRAM_VIRTUAL', severity: 'high' });
250
- if (hw?.dram === 'ambiguous') signals.push({ code: 'DRAM_AMBIGUOUS', severity: 'medium' });
251
- if (hw?.enf === 'no_grid_signal') signals.push({ code: 'NO_ENF_GRID', severity: 'medium' });
252
- if (hw?.ent != null && hw.ent < 0.35) {
253
- signals.push({ code: 'LOW_ENTROPY_SCORE', severity: 'high' });
254
- }
255
-
256
- // Idle proof layer
257
- if (!idle) {
258
- signals.push({ code: 'NO_IDLE_PROOF', severity: 'medium' });
259
- } else {
260
- if (idle.therm === 'step_function') signals.push({ code: 'STEP_FUNCTION_THERMAL', severity: 'high' });
261
- if (idle.therm === 'sustained_hot') signals.push({ code: 'SUSTAINED_LOAD_PATTERN', severity: 'high' });
262
- if (idle.mono < 0.30 && idle.s >= 3) signals.push({ code: 'NON_MONOTONIC_COOLING', severity: 'medium' });
263
- if (idle.dMs < 50_000) signals.push({ code: 'MINIMAL_IDLE_DURATION', severity: 'low' });
264
- }
265
-
266
- // Interaction layer
267
- if (evt?.mot != null && evt.mot < 0.25) {
268
- signals.push({ code: 'POOR_MOTOR_CONSISTENCY', severity: 'medium' });
269
- }
270
-
271
- return signals;
272
- }
273
-
274
- function _checkIdlePlausibility(idle) {
275
- const w = [];
276
- if (!idle.chain || idle.chain.length !== 64) w.push('malformed_chain_hash');
277
- if (idle.s < 2) w.push('insufficient_chain_samples');
278
- if (idle.therm === 'step_function') w.push('step_function_transition');
279
- if (idle.mono < 0.30 && idle.s >= 3) w.push('non_monotonic_cooling');
280
- return w;
281
- }
282
-
283
- // ── HMAC ──────────────────────────────────────────────────────────────────────
284
-
285
- function _sign({ v, n, iat, exp, idle, hw, evt }, secret) {
286
- // All fields an attacker might want to inflate/swap are in the signed body.
287
- // Advisory fields (therm, mono, dram labels) are deliberately excluded —
288
- // they're useful for risk scoring but not for access control.
289
- const body = [
290
- v,
291
- n,
292
- iat,
293
- exp,
294
- idle?.chain ?? 'null',
295
- idle?.dMs ?? 'null',
296
- hw?.ent ?? 'null',
297
- evt?.t ?? 'null',
298
- evt?.ts ?? 'null',
299
- ].join('|');
300
-
301
- const mac = hmac(sha256, utf8ToBytes(secret), utf8ToBytes(body));
302
- return bytesToHex(mac);
303
- }
304
-
305
- /**
306
- * Pure-JS timing-safe string comparison for hex strings.
307
- * Operates on char codes — constant time for equal-length inputs.
308
- * V8 does not optimize away XOR accumulation on Uint8 arithmetic.
309
- */
310
- function _timingSafeEqual(a, b) {
311
- if (typeof a !== 'string' || typeof b !== 'string') return false;
312
- if (a.length !== b.length) return false;
313
- let diff = 0;
314
- for (let i = 0; i < a.length; i++) {
315
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
316
- }
317
- return diff === 0;
318
- }
319
-
320
- // ── Encode / decode (base64url) ───────────────────────────────────────────────
321
-
322
- function _encode(token) {
323
- const bytes = utf8ToBytes(JSON.stringify(token));
324
- if (typeof Buffer !== 'undefined') {
325
- return Buffer.from(bytes).toString('base64url');
326
- }
327
- // Browser: manual base64url encoding
328
- return btoa(String.fromCharCode(...bytes))
329
- .replace(/\+/g, '-')
330
- .replace(/\//g, '_')
331
- .replace(/=/g, '');
332
- }
333
-
334
- function _decode(compact) {
335
- // Normalize base64url to standard base64 with padding
336
- let b64 = compact.replace(/-/g, '+').replace(/_/g, '/');
337
- while (b64.length % 4) b64 += '=';
338
-
339
- let bytes;
340
- if (typeof Buffer !== 'undefined') {
341
- bytes = Buffer.from(b64, 'base64');
342
- } else {
343
- const str = atob(b64);
344
- bytes = Uint8Array.from(str, c => c.charCodeAt(0));
345
- }
346
-
347
- return JSON.parse(new TextDecoder().decode(bytes));
348
- }
349
-
350
- // ── Misc helpers ──────────────────────────────────────────────────────────────
351
-
352
- function _reject(reason, meta = {}) {
353
- return { valid: false, reason, ...meta };
354
- }
355
-
356
- function _assertSecret(secret) {
357
- if (!secret || typeof secret !== 'string' || secret.length < 16) {
358
- throw new Error(
359
- '@svrnsec/pulse: engagement token secret must be ≥ 16 characters. ' +
360
- 'Generate one with: import { generateSecret } from "@svrnsec/pulse/challenge"'
361
- );
362
- }
363
- }
364
-
365
- function _extractEntropyScore(pulseResult) {
366
- // Normalize jitter score (0–1) from wherever it lives in the result tree
367
- const score =
368
- pulseResult?.payload?.classification?.jitterScore ??
369
- pulseResult?.classification?.jitterScore ??
370
- pulseResult?.jitterScore ??
371
- null;
372
- return score != null ? +Number(score).toFixed(3) : null;
373
- }
374
-
375
- // ── JSDoc types ───────────────────────────────────────────────────────────────
376
-
377
- /**
378
- * @typedef {object} EngagementToken
379
- * @property {object} token full parsed token object
380
- * @property {string} compact base64url-encoded compact form (attach to API headers)
381
- * @property {number} expiresAt Unix ms expiry timestamp
382
- */
383
-
384
- /**
385
- * @typedef {object} EngagementVerifyResult
386
- * @property {boolean} valid true if all checks passed
387
- * @property {string} [reason] rejection reason code (when valid=false)
388
- * @property {number} [expiredByMs] how many ms ago it expired (when reason=token_expired)
389
- * @property {object} [token] parsed token (when valid=true)
390
- * @property {string[]} [idleWarnings] advisory idle-proof warnings
391
- * @property {object[]} [riskSignals] non-fatal risk indicators with severity
392
- * @property {number} [issuedAt] Unix ms issued-at
393
- * @property {number} [expiresAt] Unix ms expiry
394
- */
1
+ /**
2
+ * @svrnsec/pulse — Engagement Token
3
+ *
4
+ * A short-lived, physics-backed cryptographic token that proves a specific
5
+ * engagement event (click, view, share, purchase) originated from a real
6
+ * human on real hardware that had genuinely rested between interactions.
7
+ *
8
+ * This is the layer that defeats the "1,000 phones in a warehouse" attack.
9
+ * Each token proves:
10
+ *
11
+ * 1. Real hardware DRAM refresh present, ENF grid signal detected
12
+ * 2. Genuine idle Hash-chained thermal measurements spanning ≥ 45s
13
+ * 3. Physical cooling Variance decay was smooth, not a step function
14
+ * 4. Fresh interaction 30-second TTL eliminates token brokers
15
+ * 5. Tamper-evident HMAC-SHA256 over all fraud-relevant fields
16
+ *
17
+ * Token wire format (compact: base64url JSON, ~400 bytes)
18
+ * ────────────────────────────────────────────────────────
19
+ * {
20
+ * v: 2, protocol version
21
+ * n: "hex64", nonce — 256-bit random
22
+ * iat: 1234567890123, issued-at Unix ms
23
+ * exp: 1234567920123, expires-at (iat + 30s)
24
+ * idle: {
25
+ * chain: "hex64", final hash of idle measurement chain
26
+ * s: 3, sample count (≥ 2 for a valid proof)
27
+ * dMs: 180000, idle duration ms
28
+ * therm: "hot_to_cold", thermal transition label
29
+ * mono: 0.67, cooling monotonicity (0–1)
30
+ * },
31
+ * hw: {
32
+ * dram: "dram", DRAM probe verdict
33
+ * enf: "grid_60hz", ENF probe verdict
34
+ * ent: 0.73, normalized physics entropy score (0–1)
35
+ * },
36
+ * evt: {
37
+ * t: "click", event type
38
+ * ts: 1234567890123, event Unix ms
39
+ * mot: 0.82, motor consistency (0–1)
40
+ * },
41
+ * sig: "hex64" HMAC-SHA256 over all fraud-relevant fields
42
+ * }
43
+ *
44
+ * HMAC input (pipe-delimited, all fields that matter for fraud)
45
+ * ─────────────────────────────────────────────────────────────
46
+ * `v|n|iat|exp|idle.chain|idle.dMs|hw.ent|evt.t|evt.ts`
47
+ *
48
+ * Changing any signed field invalidates the token. Unsigned fields
49
+ * (therm, mono, dram, enf) are advisory — they inform risk scoring but
50
+ * cannot be manipulated for credit fraud without breaking the HMAC.
51
+ */
52
+
53
+ import { hmac } from '@noble/hashes/hmac';
54
+ import { sha256 } from '@noble/hashes/sha256';
55
+ import { bytesToHex,
56
+ utf8ToBytes,
57
+ randomBytes } from '@noble/hashes/utils';
58
+
59
+ // ── Constants ─────────────────────────────────────────────────────────────────
60
+
61
+ const TOKEN_VERSION = 2;
62
+
63
+ /** 30-second TTL: short enough to prevent token brokers (resellers of valid
64
+ * tokens scraped from legitimate devices), long enough to survive one API
65
+ * round-trip on a slow connection. */
66
+ const TOKEN_TTL_MS = 30_000;
67
+
68
+ const NONCE_BYTES = 32; // 256-bit nonce → 64-char hex
69
+
70
+ // ── createEngagementToken ─────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Create a physics-backed engagement token.
74
+ *
75
+ * Attach the returned `compact` string to your API call:
76
+ * `fetch('/api/action', { headers: { 'X-Pulse-Token': token.compact } })`
77
+ *
78
+ * @param {object} opts
79
+ * @param {object} opts.pulseResult result object from pulse()
80
+ * @param {IdleProof|null} opts.idleProof from idleMonitor.getProof()
81
+ * @param {object} opts.interaction { type, ts, motorConsistency }
82
+ * @param {string} opts.secret shared HMAC secret (min 16 chars)
83
+ * @param {object} [opts._overrides] for deterministic testing only
84
+ * @returns {EngagementToken}
85
+ */
86
+ export function createEngagementToken(opts) {
87
+ const {
88
+ pulseResult = {},
89
+ idleProof = null,
90
+ interaction = {},
91
+ secret,
92
+ _overrides = {},
93
+ } = opts;
94
+
95
+ _assertSecret(secret);
96
+
97
+ const n = _overrides.nonce ?? bytesToHex(randomBytes(NONCE_BYTES));
98
+ const iat = _overrides.issuedAt ?? Date.now();
99
+ const exp = iat + TOKEN_TTL_MS;
100
+
101
+ // ── Extract hardware evidence from pulse result ───────────────────────────
102
+ const extended = pulseResult.extended ?? {};
103
+ const dram = extended.dram?.verdict ?? pulseResult.dram?.verdict ?? 'unavailable';
104
+ const enf = extended.enf?.verdict ?? pulseResult.enf?.verdict ?? 'unavailable';
105
+ const ent = _extractEntropyScore(pulseResult);
106
+
107
+ // ENF deviation for population-level phase coherence test
108
+ const enfDev = extended.enf?.enfDeviation
109
+ ?? pulseResult.enf?.enfDeviation
110
+ ?? null;
111
+
112
+ // ── Pack idle evidence ────────────────────────────────────────────────────
113
+ const idle = idleProof
114
+ ? {
115
+ chain: idleProof.chain,
116
+ s: idleProof.samples,
117
+ dMs: idleProof.idleDurationMs,
118
+ therm: idleProof.thermalTransition,
119
+ mono: idleProof.coolingMonotonicity,
120
+ }
121
+ : null;
122
+
123
+ // ── Pack interaction evidence ─────────────────────────────────────────────
124
+ const evt = {
125
+ t: interaction.type ?? 'unknown',
126
+ ts: interaction.ts ?? iat,
127
+ mot: +(interaction.motorConsistency ?? 0).toFixed(3),
128
+ };
129
+
130
+ // ── Sign and seal ─────────────────────────────────────────────────────────
131
+ const hw = { dram, enf, ent, ...(enfDev != null && { enfDev }) };
132
+ const unsigned = { v: TOKEN_VERSION, n, iat, exp, idle, hw, evt };
133
+ const sig = _sign(unsigned, secret);
134
+ const token = { ...unsigned, sig };
135
+
136
+ return {
137
+ token,
138
+ compact: _encode(token),
139
+ expiresAt: exp,
140
+ };
141
+ }
142
+
143
+ // ── verifyEngagementToken ─────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Verify an engagement token on the server.
147
+ *
148
+ * Call this in your API handler before crediting any engagement metric.
149
+ * Failed verification returns `{ valid: false, reason }` — never throws.
150
+ *
151
+ * @param {string|object} tokenOrCompact compact base64url string or parsed token
152
+ * @param {string} secret shared HMAC secret
153
+ * @param {object} [opts]
154
+ * @param {Function} [opts.checkNonce] async (nonce: string) => boolean
155
+ * Must atomically consume the nonce (Redis DEL returning 1,
156
+ * DB transaction with SELECT FOR UPDATE, etc.)
157
+ * @param {Function} [opts.now] override Date.now for testing: () => number
158
+ * @returns {Promise<EngagementVerifyResult>}
159
+ */
160
+ export async function verifyEngagementToken(tokenOrCompact, secret, opts = {}) {
161
+ _assertSecret(secret);
162
+
163
+ // ── Parse ─────────────────────────────────────────────────────────────────
164
+ let token;
165
+ try {
166
+ token = typeof tokenOrCompact === 'string'
167
+ ? _decode(tokenOrCompact)
168
+ : tokenOrCompact;
169
+ } catch {
170
+ return _reject('malformed_token');
171
+ }
172
+
173
+ const { v, n, iat, exp, idle, hw, evt, sig } = token ?? {};
174
+
175
+ // ── Structural integrity ──────────────────────────────────────────────────
176
+ if (v !== TOKEN_VERSION) return _reject('unsupported_version');
177
+ if (!n || !/^[0-9a-f]{64}$/i.test(n)) return _reject('invalid_nonce');
178
+ if (!Number.isFinite(iat) || !Number.isFinite(exp) || !sig) {
179
+ return _reject('missing_required_fields');
180
+ }
181
+
182
+ // ── Freshness ─────────────────────────────────────────────────────────────
183
+ const now = (opts.now ?? Date.now)();
184
+ if (now > exp) return _reject('token_expired', { expiredByMs: now - exp });
185
+ if (iat > now + 5_000) return _reject('token_from_future');
186
+
187
+ // ── Signature verification (timing-safe comparison) ───────────────────────
188
+ const { sig: _discardSig, ...unsigned } = token;
189
+ const expected = _sign(unsigned, secret);
190
+ if (!_timingSafeEqual(expected, sig)) {
191
+ return _reject('invalid_signature');
192
+ }
193
+
194
+ // ── Nonce consumption (replay prevention) ─────────────────────────────────
195
+ if (typeof opts.checkNonce === 'function') {
196
+ let consumed;
197
+ try { consumed = await opts.checkNonce(n); }
198
+ catch { return _reject('nonce_check_error'); }
199
+ if (!consumed) return _reject('nonce_replayed');
200
+ }
201
+
202
+ // ── Advisory analysis (non-blocking) ─────────────────────────────────────
203
+ const idleWarnings = idle ? _checkIdlePlausibility(idle) : ['no_idle_proof'];
204
+ const riskSignals = _assessRisk(hw, idle, evt);
205
+
206
+ return {
207
+ valid: true,
208
+ token,
209
+ idleWarnings,
210
+ riskSignals,
211
+ issuedAt: iat,
212
+ expiresAt: exp,
213
+ };
214
+ }
215
+
216
+ // ── Encode / decode ───────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Encode a token object to a compact base64url string.
220
+ * @param {object} token
221
+ * @returns {string}
222
+ */
223
+ export function encodeToken(token) {
224
+ return _encode(token);
225
+ }
226
+
227
+ /**
228
+ * Decode a compact string WITHOUT verifying the signature.
229
+ * For logging/debugging only — use verifyEngagementToken for security checks.
230
+ * Named 'Unsafe' to prevent accidental use in security-sensitive code paths.
231
+ * @param {string} compact
232
+ * @returns {object & { _verified: false }}
233
+ */
234
+ export function decodeTokenUnsafe(compact) {
235
+ return { ...(_decode(compact)), _verified: false };
236
+ }
237
+
238
+ /** @deprecated Use decodeTokenUnsafe instead */
239
+ export function decodeToken(compact) {
240
+ return decodeTokenUnsafe(compact);
241
+ }
242
+
243
+ // ── Risk assessment ───────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Advisory risk signals: concerns that don't outright invalidate the token
247
+ * but should inform downstream risk decisions.
248
+ *
249
+ * Returned in the `riskSignals` array of a successful verify result.
250
+ * Each entry: `{ code: string, severity: 'high'|'medium'|'low' }`.
251
+ */
252
+ function _assessRisk(hw, idle, evt) {
253
+ const signals = [];
254
+
255
+ // Hardware layer
256
+ if (hw?.dram === 'virtual') signals.push({ code: 'DRAM_VIRTUAL', severity: 'high' });
257
+ if (hw?.dram === 'ambiguous') signals.push({ code: 'DRAM_AMBIGUOUS', severity: 'medium' });
258
+ if (hw?.enf === 'no_grid_signal') signals.push({ code: 'NO_ENF_GRID', severity: 'medium' });
259
+ if (hw?.ent != null && hw.ent < 0.35) {
260
+ signals.push({ code: 'LOW_ENTROPY_SCORE', severity: 'high' });
261
+ }
262
+
263
+ // Idle proof layer
264
+ if (!idle) {
265
+ signals.push({ code: 'NO_IDLE_PROOF', severity: 'medium' });
266
+ } else {
267
+ if (idle.therm === 'step_function') signals.push({ code: 'STEP_FUNCTION_THERMAL', severity: 'high' });
268
+ if (idle.therm === 'sustained_hot') signals.push({ code: 'SUSTAINED_LOAD_PATTERN', severity: 'high' });
269
+ if (idle.mono < 0.30 && idle.s >= 3) signals.push({ code: 'NON_MONOTONIC_COOLING', severity: 'medium' });
270
+ if (idle.dMs < 50_000) signals.push({ code: 'MINIMAL_IDLE_DURATION', severity: 'low' });
271
+ }
272
+
273
+ // Interaction layer
274
+ if (evt?.mot != null && evt.mot < 0.25) {
275
+ signals.push({ code: 'POOR_MOTOR_CONSISTENCY', severity: 'medium' });
276
+ }
277
+
278
+ return signals;
279
+ }
280
+
281
+ function _checkIdlePlausibility(idle) {
282
+ const w = [];
283
+ if (!idle.chain || idle.chain.length !== 64) w.push('malformed_chain_hash');
284
+ if (idle.s < 2) w.push('insufficient_chain_samples');
285
+ if (idle.therm === 'step_function') w.push('step_function_transition');
286
+ if (idle.mono < 0.30 && idle.s >= 3) w.push('non_monotonic_cooling');
287
+ return w;
288
+ }
289
+
290
+ // ── HMAC ──────────────────────────────────────────────────────────────────────
291
+
292
+ function _sign({ v, n, iat, exp, idle, hw, evt }, secret) {
293
+ // All fields an attacker might want to inflate/swap are in the signed body.
294
+ // Advisory fields (therm, mono, dram labels) are deliberately excluded —
295
+ // they're useful for risk scoring but not for access control.
296
+ const body = [
297
+ v,
298
+ n,
299
+ iat,
300
+ exp,
301
+ idle?.chain ?? 'null',
302
+ idle?.dMs ?? 'null',
303
+ hw?.ent ?? 'null',
304
+ evt?.t ?? 'null',
305
+ evt?.ts ?? 'null',
306
+ ].join('|');
307
+
308
+ const mac = hmac(sha256, utf8ToBytes(secret), utf8ToBytes(body));
309
+ return bytesToHex(mac);
310
+ }
311
+
312
+ /**
313
+ * Timing-safe hex string comparison.
314
+ * Uses Node.js crypto.timingSafeEqual when available (server-side),
315
+ * falls back to constant-time XOR accumulation for browser contexts.
316
+ */
317
+ let _nodeTse = null;
318
+ let _nodeTseLoaded = false;
319
+
320
+ function _timingSafeEqual(a, b) {
321
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
322
+ if (a.length !== b.length) return false;
323
+
324
+ // Try Node.js built-in (loaded once, cached)
325
+ if (!_nodeTseLoaded) {
326
+ _nodeTseLoaded = true;
327
+ try {
328
+ // eslint-disable-next-line no-eval -- dynamic require avoids bundler issues
329
+ const crypto = typeof require === 'function'
330
+ ? require('node:crypto')
331
+ : null;
332
+ if (crypto?.timingSafeEqual) _nodeTse = crypto.timingSafeEqual;
333
+ } catch { /* browser — no node:crypto */ }
334
+ }
335
+
336
+ if (_nodeTse) {
337
+ try {
338
+ const bufA = Buffer.from(a, 'hex');
339
+ const bufB = Buffer.from(b, 'hex');
340
+ return bufA.length === bufB.length && _nodeTse(bufA, bufB);
341
+ } catch { /* fall through to XOR */ }
342
+ }
343
+
344
+ // Constant-time XOR accumulation fallback
345
+ let diff = 0;
346
+ for (let i = 0; i < a.length; i++) {
347
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
348
+ }
349
+ return diff === 0;
350
+ }
351
+
352
+ // ── Encode / decode (base64url) ───────────────────────────────────────────────
353
+
354
+ function _encode(token) {
355
+ const bytes = utf8ToBytes(JSON.stringify(token));
356
+ if (typeof Buffer !== 'undefined') {
357
+ return Buffer.from(bytes).toString('base64url');
358
+ }
359
+ // Browser: manual base64url encoding
360
+ return btoa(String.fromCharCode(...bytes))
361
+ .replace(/\+/g, '-')
362
+ .replace(/\//g, '_')
363
+ .replace(/=/g, '');
364
+ }
365
+
366
+ function _decode(compact) {
367
+ // Normalize base64url to standard base64 with padding
368
+ let b64 = compact.replace(/-/g, '+').replace(/_/g, '/');
369
+ while (b64.length % 4) b64 += '=';
370
+
371
+ let bytes;
372
+ if (typeof Buffer !== 'undefined') {
373
+ bytes = Buffer.from(b64, 'base64');
374
+ } else {
375
+ const str = atob(b64);
376
+ bytes = Uint8Array.from(str, c => c.charCodeAt(0));
377
+ }
378
+
379
+ return JSON.parse(new TextDecoder().decode(bytes));
380
+ }
381
+
382
+ // ── Misc helpers ──────────────────────────────────────────────────────────────
383
+
384
+ function _reject(reason, meta = {}) {
385
+ return { valid: false, reason, ...meta };
386
+ }
387
+
388
+ function _assertSecret(secret) {
389
+ if (!secret || typeof secret !== 'string' || secret.length < 32) {
390
+ throw new Error(
391
+ '@svrnsec/pulse: engagement token secret must be 32 characters (256 bits). ' +
392
+ 'Generate one with: import { generateSecret } from "@svrnsec/pulse/challenge"'
393
+ );
394
+ }
395
+ }
396
+
397
+ function _extractEntropyScore(pulseResult) {
398
+ // Normalize jitter score (0–1) from wherever it lives in the result tree
399
+ const score =
400
+ pulseResult?.payload?.classification?.jitterScore ??
401
+ pulseResult?.classification?.jitterScore ??
402
+ pulseResult?.jitterScore ??
403
+ null;
404
+ return score != null ? +Number(score).toFixed(3) : null;
405
+ }
406
+
407
+ // ── JSDoc types ───────────────────────────────────────────────────────────────
408
+
409
+ /**
410
+ * @typedef {object} EngagementToken
411
+ * @property {object} token full parsed token object
412
+ * @property {string} compact base64url-encoded compact form (attach to API headers)
413
+ * @property {number} expiresAt Unix ms expiry timestamp
414
+ */
415
+
416
+ /**
417
+ * @typedef {object} EngagementVerifyResult
418
+ * @property {boolean} valid true if all checks passed
419
+ * @property {string} [reason] rejection reason code (when valid=false)
420
+ * @property {number} [expiredByMs] how many ms ago it expired (when reason=token_expired)
421
+ * @property {object} [token] parsed token (when valid=true)
422
+ * @property {string[]} [idleWarnings] advisory idle-proof warnings
423
+ * @property {object[]} [riskSignals] non-fatal risk indicators with severity
424
+ * @property {number} [issuedAt] Unix ms issued-at
425
+ * @property {number} [expiresAt] Unix ms expiry
426
+ */