@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
|
+
*/
|