@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.
- package/bin/svrnsec-pulse.js +7 -0
- package/index.d.ts +130 -0
- package/package.json +70 -25
- package/src/analysis/audio.js +213 -0
- package/src/analysis/coherence.js +502 -0
- package/src/analysis/heuristic.js +428 -0
- package/src/analysis/jitter.js +446 -0
- package/src/analysis/llm.js +472 -0
- package/src/analysis/populationEntropy.js +403 -0
- package/src/analysis/provider.js +248 -0
- package/src/analysis/trustScore.js +356 -0
- package/src/cli/args.js +36 -0
- package/src/cli/commands/scan.js +192 -0
- package/src/cli/runner.js +157 -0
- package/src/collector/adaptive.js +200 -0
- package/src/collector/bio.js +287 -0
- package/src/collector/canvas.js +239 -0
- package/src/collector/dram.js +203 -0
- package/src/collector/enf.js +311 -0
- package/src/collector/entropy.js +195 -0
- package/src/collector/gpu.js +245 -0
- package/src/collector/idleAttestation.js +480 -0
- package/src/collector/sabTimer.js +191 -0
- package/src/fingerprint.js +475 -0
- package/src/index.js +342 -0
- package/src/integrations/react-native.js +459 -0
- package/src/proof/challenge.js +249 -0
- package/src/proof/engagementToken.js +394 -0
- package/src/terminal.js +263 -0
- package/src/update-notifier.js +264 -0
|
@@ -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
|
+
*/
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — Terminal Result Renderer
|
|
3
|
+
*
|
|
4
|
+
* Pretty-prints probe results to the terminal for Node.js server usage.
|
|
5
|
+
* Used by middleware and the CLI so developers see clean, actionable output
|
|
6
|
+
* during integration and debugging — not raw JSON.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies. Pure ANSI escape codes.
|
|
9
|
+
* Automatically disabled when stdout is not a TTY or NO_COLOR is set.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* ─── TTY guard ──────────────────────────────────────────────────────────── */
|
|
13
|
+
|
|
14
|
+
const isTTY = () =>
|
|
15
|
+
typeof process !== 'undefined' &&
|
|
16
|
+
process.stderr?.isTTY === true &&
|
|
17
|
+
process.env?.NO_COLOR == null;
|
|
18
|
+
|
|
19
|
+
const c = isTTY;
|
|
20
|
+
|
|
21
|
+
/* ─── ANSI color palette ─────────────────────────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
const A = {
|
|
24
|
+
reset: '\x1b[0m',
|
|
25
|
+
bold: '\x1b[1m',
|
|
26
|
+
dim: '\x1b[2m',
|
|
27
|
+
// foreground — normal
|
|
28
|
+
red: '\x1b[31m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
yellow: '\x1b[33m',
|
|
31
|
+
blue: '\x1b[34m',
|
|
32
|
+
magenta: '\x1b[35m',
|
|
33
|
+
cyan: '\x1b[36m',
|
|
34
|
+
white: '\x1b[37m',
|
|
35
|
+
gray: '\x1b[90m',
|
|
36
|
+
// foreground — bright
|
|
37
|
+
bred: '\x1b[91m',
|
|
38
|
+
bgreen: '\x1b[92m',
|
|
39
|
+
byellow: '\x1b[93m',
|
|
40
|
+
bblue: '\x1b[94m',
|
|
41
|
+
bmagenta:'\x1b[95m',
|
|
42
|
+
bcyan: '\x1b[96m',
|
|
43
|
+
bwhite: '\x1b[97m',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const paint = (code, s) => c() ? `${code}${s}${A.reset}` : s;
|
|
47
|
+
const dim = (s) => paint(A.dim, s);
|
|
48
|
+
const bold = (s) => paint(A.bold, s);
|
|
49
|
+
const gray = (s) => paint(A.gray, s);
|
|
50
|
+
const cyan = (s) => paint(A.cyan, s);
|
|
51
|
+
const green = (s) => paint(A.bgreen, s);
|
|
52
|
+
const red = (s) => paint(A.bred, s);
|
|
53
|
+
const yel = (s) => paint(A.byellow, s);
|
|
54
|
+
const mag = (s) => paint(A.bmagenta,s);
|
|
55
|
+
const wh = (s) => paint(A.bwhite, s);
|
|
56
|
+
|
|
57
|
+
function stripAnsi(s) {
|
|
58
|
+
// eslint-disable-next-line no-control-regex
|
|
59
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
60
|
+
}
|
|
61
|
+
const visLen = (s) => stripAnsi(s).length;
|
|
62
|
+
|
|
63
|
+
/* ─── bar renderer ───────────────────────────────────────────────────────── */
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render a horizontal progress / confidence bar.
|
|
67
|
+
* @param {number} pct 0–1
|
|
68
|
+
* @param {number} width character width of the bar
|
|
69
|
+
* @param {string} fillCode ANSI color code for filled blocks
|
|
70
|
+
*/
|
|
71
|
+
function bar(pct, width = 20, fillCode = A.bgreen) {
|
|
72
|
+
const filled = Math.round(Math.min(1, Math.max(0, pct)) * width);
|
|
73
|
+
const empty = width - filled;
|
|
74
|
+
const fill = c() ? `${fillCode}${'█'.repeat(filled)}${A.reset}` : '█'.repeat(filled);
|
|
75
|
+
const void_ = gray('░'.repeat(empty));
|
|
76
|
+
return fill + void_;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ─── verdict badge ──────────────────────────────────────────────────────── */
|
|
80
|
+
|
|
81
|
+
function verdictBadge(result) {
|
|
82
|
+
if (!result) return gray(' PENDING ');
|
|
83
|
+
const { valid, score, confidence } = result;
|
|
84
|
+
|
|
85
|
+
if (valid && confidence === 'high') return green(' ✓ PASS ');
|
|
86
|
+
if (valid && confidence === 'medium') return yel(' ⚠ PASS ');
|
|
87
|
+
if (!valid && score < 0.3) return red(' ✗ BLOCKED ');
|
|
88
|
+
return yel(' ⚠ REVIEW ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ─── renderProbeResult ──────────────────────────────────────────────────── */
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Print a formatted probe result card to stderr.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} opts
|
|
97
|
+
* @param {object} opts.payload - ProofPayload from pulse()
|
|
98
|
+
* @param {string} opts.hash - BLAKE3 hex commitment
|
|
99
|
+
* @param {object} [opts.result] - ValidationResult (server-side verify)
|
|
100
|
+
* @param {object} [opts.enf] - EnfResult if available
|
|
101
|
+
* @param {object} [opts.gpu] - GpuEntropyResult if available
|
|
102
|
+
* @param {object} [opts.dram] - DramResult if available
|
|
103
|
+
* @param {object} [opts.llm] - LlmResult if available
|
|
104
|
+
* @param {number} [opts.elapsedMs] - total probe time
|
|
105
|
+
*/
|
|
106
|
+
export function renderProbeResult({ payload, hash, result, enf, gpu, dram, llm, elapsedMs }) {
|
|
107
|
+
if (!c()) return;
|
|
108
|
+
|
|
109
|
+
const W = 54;
|
|
110
|
+
const hr = gray('─'.repeat(W));
|
|
111
|
+
const vbar = gray('│');
|
|
112
|
+
|
|
113
|
+
const row = (label, value, valueColor = A.bwhite) => {
|
|
114
|
+
const lbl = gray(label.padEnd(24));
|
|
115
|
+
const val = c() ? `${valueColor}${value}${A.reset}` : String(value);
|
|
116
|
+
const line = ` ${lbl}${val}`;
|
|
117
|
+
const pad = ' '.repeat(Math.max(0, W - visLen(line) - 2));
|
|
118
|
+
process.stderr.write(`${vbar}${line}${pad} ${vbar}\n`);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const blank = () => {
|
|
122
|
+
process.stderr.write(`${vbar}${' '.repeat(W + 2)}${vbar}\n`);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const section = (title) => {
|
|
126
|
+
const t = ` ${bold(title)}`;
|
|
127
|
+
const pad = ' '.repeat(Math.max(0, W - visLen(t) - 2));
|
|
128
|
+
process.stderr.write(`${vbar}${t}${pad} ${vbar}\n`);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const badge = verdictBadge(result);
|
|
132
|
+
const hashShort = hash ? hash.slice(0, 16) + '…' : 'pending';
|
|
133
|
+
const elapsed = elapsedMs ? `${(elapsedMs / 1000).toFixed(2)}s` : '—';
|
|
134
|
+
|
|
135
|
+
const sigs = payload?.signals ?? {};
|
|
136
|
+
const cls = payload?.classification ?? {};
|
|
137
|
+
const jScore = cls.jitterScore ?? 0;
|
|
138
|
+
|
|
139
|
+
// ── Physics signals ──────────────────────────────────────────────────────
|
|
140
|
+
const qe = sigs.entropy?.quantizationEntropy ?? 0;
|
|
141
|
+
const hurst = sigs.entropy?.hurstExponent ?? 0;
|
|
142
|
+
const cv = sigs.entropy?.timingsCV ?? 0;
|
|
143
|
+
const ejrClass = qe >= 1.08 ? A.bgreen : qe >= 0.95 ? A.byellow : A.bred;
|
|
144
|
+
const hwConf = result?.confidence === 'high' ? 1.0 : result?.confidence === 'medium' ? 0.65 : 0.3;
|
|
145
|
+
const vmConf = 1 - hwConf;
|
|
146
|
+
|
|
147
|
+
// ── ENF signals ──────────────────────────────────────────────────────────
|
|
148
|
+
const enfRegion = enf?.gridRegion === 'americas' ? '60 Hz Americas'
|
|
149
|
+
: enf?.gridRegion === 'emea_apac' ? '50 Hz EMEA/APAC'
|
|
150
|
+
: enf?.enfAvailable === false ? 'unavailable'
|
|
151
|
+
: '—';
|
|
152
|
+
const enfColor = enf?.ripplePresent ? A.bgreen : enf?.enfAvailable === false ? A.gray : A.byellow;
|
|
153
|
+
|
|
154
|
+
// ── GPU signals ──────────────────────────────────────────────────────────
|
|
155
|
+
const gpuStr = gpu?.gpuPresent
|
|
156
|
+
? (gpu.isSoftware ? red('Software renderer') : green(gpu.vendorString ?? 'GPU detected'))
|
|
157
|
+
: gray('unavailable');
|
|
158
|
+
|
|
159
|
+
// ── DRAM signals ─────────────────────────────────────────────────────────
|
|
160
|
+
const dramStr = dram?.refreshPresent
|
|
161
|
+
? green(`${(dram.refreshPeriodMs ?? 0).toFixed(1)} ms (DDR4 JEDEC ✓)`)
|
|
162
|
+
: dram ? red('No refresh cycle (VM)') : gray('unavailable');
|
|
163
|
+
|
|
164
|
+
// ── LLM signals ──────────────────────────────────────────────────────────
|
|
165
|
+
const llmStr = llm
|
|
166
|
+
? (llm.aiConf > 0.7 ? red(`AI agent ${(llm.aiConf * 100).toFixed(0)}%`) : green(`Human ${((1 - llm.aiConf) * 100).toFixed(0)}%`))
|
|
167
|
+
: gray('no bio data');
|
|
168
|
+
|
|
169
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
170
|
+
const topTitle = ` ${mag('SVRN')}${wh(':PULSE')} ${badge}`;
|
|
171
|
+
const topPad = ' '.repeat(Math.max(0, W - visLen(topTitle) - 2));
|
|
172
|
+
const topBorder = gray('╭' + '─'.repeat(W + 2) + '╮');
|
|
173
|
+
const botBorder = gray('╰' + '─'.repeat(W + 2) + '╯');
|
|
174
|
+
|
|
175
|
+
process.stderr.write('\n');
|
|
176
|
+
process.stderr.write(topBorder + '\n');
|
|
177
|
+
process.stderr.write(`${vbar}${topTitle}${topPad} ${vbar}\n`);
|
|
178
|
+
process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
179
|
+
blank();
|
|
180
|
+
|
|
181
|
+
section('PHYSICS LAYER');
|
|
182
|
+
blank();
|
|
183
|
+
row('Jitter score', (jScore * 100).toFixed(1) + '%', jScore > 0.7 ? A.bgreen : jScore > 0.45 ? A.byellow : A.bred);
|
|
184
|
+
row('QE (entropy)', qe.toFixed(3), ejrClass);
|
|
185
|
+
row('Hurst exponent', hurst.toFixed(4), Math.abs(hurst - 0.5) < 0.1 ? A.bgreen : A.byellow);
|
|
186
|
+
row('Timing CV', cv.toFixed(4), cv > 0.08 ? A.bgreen : A.byellow);
|
|
187
|
+
row('Timer granularity',`${((sigs.entropy?.timerGranularityMs ?? 0) * 1000).toFixed(1)} µs`, A.bcyan);
|
|
188
|
+
blank();
|
|
189
|
+
|
|
190
|
+
const hwBar = bar(hwConf, 18, A.bgreen);
|
|
191
|
+
const vmBar = bar(vmConf, 18, A.bred);
|
|
192
|
+
row('HW confidence', hwBar + ' ' + (hwConf * 100).toFixed(0) + '%');
|
|
193
|
+
row('VM confidence', vmBar + ' ' + (vmConf * 100).toFixed(0) + '%');
|
|
194
|
+
|
|
195
|
+
blank();
|
|
196
|
+
process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
197
|
+
blank();
|
|
198
|
+
|
|
199
|
+
section('SIGNAL LAYERS');
|
|
200
|
+
blank();
|
|
201
|
+
row('Grid (ENF)', enfRegion, enfColor);
|
|
202
|
+
process.stderr.write(`${vbar} ${gray('GPU (thermal)')}${' '.repeat(10)}${gpuStr} ${vbar}\n`);
|
|
203
|
+
process.stderr.write(`${vbar} ${gray('DRAM refresh')} ${' '.repeat(11)}${dramStr} ${vbar}\n`);
|
|
204
|
+
process.stderr.write(`${vbar} ${gray('Behavioral (LLM)')}${' '.repeat(7)}${llmStr} ${vbar}\n`);
|
|
205
|
+
|
|
206
|
+
blank();
|
|
207
|
+
process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
208
|
+
blank();
|
|
209
|
+
|
|
210
|
+
section('PROOF');
|
|
211
|
+
blank();
|
|
212
|
+
row('BLAKE3', hashShort, A.bcyan);
|
|
213
|
+
row('Nonce', (payload?.nonce ?? '').slice(0, 16) + '…', A.gray);
|
|
214
|
+
row('Elapsed', elapsed, A.gray);
|
|
215
|
+
if (result) {
|
|
216
|
+
row('Server verdict', result.valid ? 'valid' : 'rejected', result.valid ? A.bgreen : A.bred);
|
|
217
|
+
row('Score', ((result.score ?? 0) * 100).toFixed(1) + '%', A.bwhite);
|
|
218
|
+
if ((result.riskFlags ?? []).length > 0) {
|
|
219
|
+
blank();
|
|
220
|
+
row('Risk flags', result.riskFlags.join(', '), A.byellow);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
blank();
|
|
224
|
+
process.stderr.write(botBorder + '\n\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* ─── renderError ────────────────────────────────────────────────────────── */
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Print a formatted error card for pulse() failures.
|
|
231
|
+
* @param {Error|string} err
|
|
232
|
+
*/
|
|
233
|
+
export function renderError(err) {
|
|
234
|
+
if (!c()) return;
|
|
235
|
+
const msg = err?.message ?? String(err);
|
|
236
|
+
const W = 54;
|
|
237
|
+
const vbar = gray('│');
|
|
238
|
+
|
|
239
|
+
process.stderr.write('\n');
|
|
240
|
+
process.stderr.write(red('╭' + '─'.repeat(W + 2) + '╮') + '\n');
|
|
241
|
+
process.stderr.write(`${red('│')} ${red('✗')} ${bold('SVRN:PULSE — probe failed')}${' '.repeat(Math.max(0, W - 28))} ${red('│')}\n`);
|
|
242
|
+
process.stderr.write(red('├' + '─'.repeat(W + 2) + '┤') + '\n');
|
|
243
|
+
process.stderr.write(`${vbar} ${gray(msg.slice(0, W - 2).padEnd(W))} ${vbar}\n`);
|
|
244
|
+
process.stderr.write(red('╰' + '─'.repeat(W + 2) + '╯') + '\n\n');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ─── renderUpdateBanner ─────────────────────────────────────────────────── */
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Render a simple one-line update available hint inline (used by middleware).
|
|
251
|
+
* @param {string} latest
|
|
252
|
+
*/
|
|
253
|
+
export function renderInlineUpdateHint(latest) {
|
|
254
|
+
if (!c()) return;
|
|
255
|
+
process.stderr.write(
|
|
256
|
+
gray(' ╴╴╴ ') +
|
|
257
|
+
yel('update available ') +
|
|
258
|
+
gray(latest) +
|
|
259
|
+
' ' + cyan('npm i @svrnsec/pulse@latest') +
|
|
260
|
+
gray(' ╴╴╴') +
|
|
261
|
+
'\n'
|
|
262
|
+
);
|
|
263
|
+
}
|