@svrnsec/pulse 0.4.0 → 0.6.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,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 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
+ */