@svrnsec/pulse 0.1.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,212 @@
1
+ /**
2
+ * @sovereign/pulse — Hardware Fingerprint & Proof Builder
3
+ *
4
+ * Assembles all collected signals into a canonical ProofPayload, then
5
+ * produces a BLAKE3 commitment: BLAKE3(canonicalJSON(payload)).
6
+ *
7
+ * The commitment is what gets sent to the server. The server recomputes
8
+ * the hash from the payload to detect tampering. Raw timing arrays and
9
+ * pixel buffers are NOT included — only statistical summaries.
10
+ *
11
+ * Zero-Knowledge property: the server learns only that the device passes
12
+ * statistical thresholds. It never sees raw hardware telemetry.
13
+ */
14
+
15
+ import { blake3 } from '@noble/hashes/blake3';
16
+ import { bytesToHex } from '@noble/hashes/utils';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // BLAKE3 helpers (re-exported for use by canvas.js etc.)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Compute BLAKE3 of a Uint8Array and return hex string.
24
+ * @param {Uint8Array} data
25
+ * @returns {string}
26
+ */
27
+ export function blake3Hex(data) {
28
+ return bytesToHex(blake3(data));
29
+ }
30
+
31
+ /**
32
+ * Compute BLAKE3 of a UTF-8 string and return hex string.
33
+ * @param {string} str
34
+ * @returns {string}
35
+ */
36
+ export function blake3HexStr(str) {
37
+ return blake3Hex(new TextEncoder().encode(str));
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // buildProof
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Assembles a ProofPayload from all collected signals.
46
+ * This is the canonical structure that gets hashed into the commitment.
47
+ *
48
+ * @param {object} p
49
+ * @param {import('../collector/entropy.js').EntropyResult} p.entropy
50
+ * @param {import('../analysis/jitter.js').JitterAnalysis} p.jitter
51
+ * @param {import('../collector/bio.js').BioSnapshot} p.bio
52
+ * @param {import('../collector/canvas.js').CanvasFingerprint} p.canvas
53
+ * @param {import('../analysis/audio.js').AudioJitter} p.audio
54
+ * @param {string} p.nonce – server-issued challenge nonce (hex)
55
+ * @returns {ProofPayload}
56
+ */
57
+ export function buildProof({ entropy, jitter, bio, canvas, audio, nonce }) {
58
+ if (!nonce || typeof nonce !== 'string') {
59
+ throw new Error('@sovereign/pulse: nonce is required for anti-replay protection');
60
+ }
61
+
62
+ // Hash the raw timing arrays IN-BROWSER so we can prove their integrity
63
+ // without transmitting the raw data.
64
+ const timingsHash = blake3HexStr(JSON.stringify(entropy.timings));
65
+ const memHash = blake3HexStr(JSON.stringify(entropy.memTimings));
66
+
67
+ const payload = {
68
+ version: 1,
69
+ timestamp: entropy.collectedAt,
70
+ nonce,
71
+
72
+ signals: {
73
+ // ── Entropy probe ───────────────────────────────────────────────────
74
+ entropy: {
75
+ timingsMean: _round(jitter.stats?.mean, 4),
76
+ timingsCV: _round(jitter.stats?.cv, 4),
77
+ timingsP50: _round(jitter.stats?.p50, 4),
78
+ timingsP95: _round(jitter.stats?.p95, 4),
79
+ timingsSkewness: _round(jitter.stats?.skewness, 4),
80
+ timingsKurtosis: _round(jitter.stats?.kurtosis, 4),
81
+ autocorr_lag1: _round(jitter.autocorrelations?.lag1, 4),
82
+ autocorr_lag2: _round(jitter.autocorrelations?.lag2, 4),
83
+ autocorr_lag5: _round(jitter.autocorrelations?.lag5, 4),
84
+ autocorr_lag10: _round(jitter.autocorrelations?.lag10, 4),
85
+ hurstExponent: _round(jitter.hurstExponent, 4),
86
+ quantizationEntropy: _round(jitter.quantizationEntropy, 4),
87
+ thermalDrift: _round(jitter.thermalSignature?.slope, 8),
88
+ thermalPattern: jitter.thermalSignature?.pattern ?? 'unknown',
89
+ outlierRate: _round(jitter.outlierRate, 4),
90
+ timerGranularityMs: _round(entropy.timerGranularityMs, 6),
91
+ checksum: entropy.checksum, // proves computation ran
92
+ timingsHash, // proves timing array integrity
93
+ memTimingsHash: memHash,
94
+ iterations: entropy.iterations,
95
+ matrixSize: entropy.matrixSize,
96
+ },
97
+
98
+ // ── Bio signals ─────────────────────────────────────────────────────
99
+ bio: {
100
+ mouseSampleCount: bio.mouse.sampleCount,
101
+ mouseIEIMean: _round(bio.mouse.ieiMean, 3),
102
+ mouseIEICV: _round(bio.mouse.ieiCV, 4),
103
+ mouseVelocityP50: _round(bio.mouse.velocityP50, 3),
104
+ mouseVelocityP95: _round(bio.mouse.velocityP95, 3),
105
+ mouseAngularJerkMean: _round(bio.mouse.angularJerkMean, 4),
106
+ pressureVariance: _round(bio.mouse.pressureVariance, 6),
107
+ keyboardSampleCount: bio.keyboard.sampleCount,
108
+ keyboardDwellMean: _round(bio.keyboard.dwellMean, 3),
109
+ keyboardDwellCV: _round(bio.keyboard.dwellCV, 4),
110
+ keyboardIKIMean: _round(bio.keyboard.ikiMean, 3),
111
+ keyboardIKICV: _round(bio.keyboard.ikiCV, 4),
112
+ interferenceCoefficient: _round(bio.interferenceCoefficient, 4),
113
+ hasActivity: bio.hasActivity,
114
+ durationMs: _round(bio.durationMs, 1),
115
+ },
116
+
117
+ // ── Canvas fingerprint ───────────────────────────────────────────────
118
+ canvas: {
119
+ webglRenderer: canvas.webglRenderer,
120
+ webglVendor: canvas.webglVendor,
121
+ webglVersion: canvas.webglVersion,
122
+ webglPixelHash: canvas.webglPixelHash,
123
+ canvas2dHash: canvas.canvas2dHash,
124
+ extensionCount: canvas.extensionCount,
125
+ isSoftwareRenderer: canvas.isSoftwareRenderer,
126
+ available: canvas.available,
127
+ },
128
+
129
+ // ── Audio jitter ─────────────────────────────────────────────────────
130
+ audio: {
131
+ available: audio.available,
132
+ workletAvailable: audio.workletAvailable,
133
+ callbackJitterCV: _round(audio.callbackJitterCV, 4),
134
+ noiseFloorMean: _round(audio.noiseFloorMean, 6),
135
+ noiseFloorStd: _round(audio.noiseFloorStd, 6),
136
+ sampleRate: audio.sampleRate,
137
+ callbackCount: audio.callbackCount,
138
+ jitterMeanMs: _round(audio.jitterMeanMs, 4),
139
+ jitterP95Ms: _round(audio.jitterP95Ms, 4),
140
+ },
141
+ },
142
+
143
+ // Top-level classification summary
144
+ classification: {
145
+ jitterScore: _round(jitter.score, 4),
146
+ flags: jitter.flags ?? [],
147
+ },
148
+ };
149
+
150
+ return payload;
151
+ }
152
+
153
+ /**
154
+ * @typedef {object} ProofPayload
155
+ * @property {number} version
156
+ * @property {number} timestamp
157
+ * @property {string} nonce
158
+ * @property {object} signals
159
+ * @property {object} classification
160
+ */
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // buildCommitment
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Hashes a ProofPayload into a BLAKE3 commitment.
168
+ * Uses a deterministic canonical JSON serialiser (sorted keys) to ensure
169
+ * byte-identical output across JS engines.
170
+ *
171
+ * @param {ProofPayload} payload
172
+ * @returns {{ payload: ProofPayload, hash: string }}
173
+ */
174
+ export function buildCommitment(payload) {
175
+ const canonical = canonicalJson(payload);
176
+ const hash = blake3HexStr(canonical);
177
+ return { payload, hash };
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // canonicalJson
182
+ //
183
+ // JSON.stringify with sorted keys — ensures the hash is engine-independent.
184
+ // Numbers are serialised with fixed precision to avoid cross-platform float
185
+ // formatting differences.
186
+ // ---------------------------------------------------------------------------
187
+
188
+ export function canonicalJson(obj) {
189
+ return JSON.stringify(obj, _replacer, 0);
190
+ }
191
+
192
+ function _replacer(key, value) {
193
+ // Sort object keys deterministically
194
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
195
+ const sorted = {};
196
+ for (const k of Object.keys(value).sort()) {
197
+ sorted[k] = value[k];
198
+ }
199
+ return sorted;
200
+ }
201
+ return value;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Internal utilities
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function _round(v, decimals) {
209
+ if (v == null || !isFinite(v)) return null;
210
+ const factor = 10 ** decimals;
211
+ return Math.round(v * factor) / factor;
212
+ }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * @sovereign/pulse — Server-Side Validator
3
+ *
4
+ * Verifies a ProofPayload + BLAKE3 commitment received from the client.
5
+ * This module is for NODE.JS / SERVER use only. It should NOT be bundled
6
+ * into the browser build (see package.json "exports" field).
7
+ *
8
+ * Trust model:
9
+ * • The server issues a challenge `nonce` before the client runs pulse().
10
+ * • The client returns { payload, hash }.
11
+ * • The server calls validateProof(payload, hash, options) to:
12
+ * 1. Verify hash integrity (no tampering).
13
+ * 2. Verify nonce freshness (no replay).
14
+ * 3. Verify timestamp recency.
15
+ * 4. Check jitter score against thresholds.
16
+ * 5. Check canvas fingerprint against software-renderer blocklist.
17
+ * 6. Cross-validate signal consistency.
18
+ *
19
+ * NOTE: The server NEVER sees raw timing arrays or mouse coordinates.
20
+ * Only statistical summaries are transmitted. This is the ZK property.
21
+ */
22
+
23
+ import { blake3 } from '@noble/hashes/blake3';
24
+ import { bytesToHex } from '@noble/hashes/utils';
25
+ import { canonicalJson } from './fingerprint.js';
26
+ import { computeServerDynamicThreshold } from '../analysis/coherence.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Known software / virtual renderer substring patterns (lowercase)
30
+ // ---------------------------------------------------------------------------
31
+ const VM_RENDERER_BLOCKLIST = [
32
+ // Software / virtual renderers
33
+ 'llvmpipe', 'swiftshader', 'softpipe', 'mesa offscreen',
34
+ 'microsoft basic render', 'vmware svga', 'vmware', 'virtualbox',
35
+ 'parallels', 'chromium swiftshader', 'google swiftshader',
36
+ 'angle (', 'cirrussm', 'qxl', 'virtio', 'bochs',
37
+ // NVIDIA datacenter / inference — no consumer unit has these
38
+ 'nvidia t4', // AWS/GCP inference VM
39
+ 'nvidia a10g', // AWS g5 inference
40
+ 'nvidia a100', // Datacenter A100
41
+ 'nvidia h100', // Hopper — datacenter only
42
+ 'nvidia h200', // Hopper successor — datacenter only
43
+ 'nvidia b100', // Blackwell — datacenter only
44
+ 'nvidia b200', // Blackwell Ultra — datacenter only
45
+ 'nvidia gh200', // Grace-Hopper superchip
46
+ // AMD datacenter / HPC — no consumer has these
47
+ 'amd instinct', // covers mi100, mi200, mi250, mi300 family
48
+ 'amd mi300',
49
+ 'amd mi250',
50
+ 'amd mi200',
51
+ // Cloud-specific AI accelerators
52
+ 'aws inferentia',
53
+ 'aws trainium',
54
+ 'google tpu',
55
+ ];
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // validateProof
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Validates a client-submitted proof.
63
+ *
64
+ * @param {import('./fingerprint.js').ProofPayload} payload
65
+ * @param {string} receivedHash - hex BLAKE3 from the client
66
+ * @param {object} [opts]
67
+ * @param {number} [opts.minJitterScore=0.55] - minimum acceptable jitter score
68
+ * @param {number} [opts.maxAgeMs=300_000] - max payload age (5 min)
69
+ * @param {number} [opts.clockSkewMs=30_000] - tolerated future timestamp drift
70
+ * @param {boolean} [opts.requireBio=false] - reject if no bio activity
71
+ * @param {boolean} [opts.blockSoftwareRenderer=true] - reject software WebGL
72
+ * @param {Function} [opts.checkNonce] - async fn(nonce) → boolean
73
+ * Called to verify the nonce was issued by this server and not yet consumed.
74
+ * Should mark the nonce as consumed atomically (e.g. Redis SET NX with TTL).
75
+ * If omitted, nonce freshness is NOT checked (not recommended for production).
76
+ *
77
+ * @returns {Promise<ValidationResult>}
78
+ */
79
+ export async function validateProof(payload, receivedHash, opts = {}) {
80
+ const {
81
+ minJitterScore = 0.55,
82
+ maxAgeMs = 300_000,
83
+ clockSkewMs = 30_000,
84
+ requireBio = false,
85
+ blockSoftwareRenderer = true,
86
+ checkNonce = null,
87
+ } = opts;
88
+
89
+ const reasons = [];
90
+ const riskFlags = [];
91
+ let valid = true;
92
+
93
+ // ── 0. Strict payload structure validation ────────────────────────────────
94
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
95
+ return _reject(['INVALID_PAYLOAD_STRUCTURE']);
96
+ }
97
+
98
+ // Prototype pollution guard — reject any payload with __proto__ / constructor tricks
99
+ if (
100
+ Object.prototype.hasOwnProperty.call(payload, '__proto__') ||
101
+ Object.prototype.hasOwnProperty.call(payload, 'constructor') ||
102
+ Object.prototype.hasOwnProperty.call(payload, 'prototype')
103
+ ) {
104
+ return _reject(['PROTOTYPE_POLLUTION_ATTEMPT']);
105
+ }
106
+
107
+ // Required top-level fields
108
+ const REQUIRED_TOP = ['version', 'timestamp', 'nonce', 'signals', 'classification'];
109
+ for (const field of REQUIRED_TOP) {
110
+ if (!(field in payload)) {
111
+ return _reject([`MISSING_REQUIRED_FIELD:${field}`]);
112
+ }
113
+ }
114
+
115
+ // Type assertions on top-level scalars
116
+ if (typeof payload.version !== 'number') return _reject(['INVALID_TYPE:version']);
117
+ if (typeof payload.timestamp !== 'number') return _reject(['INVALID_TYPE:timestamp']);
118
+ if (typeof payload.nonce !== 'string') return _reject(['INVALID_TYPE:nonce']);
119
+ if (typeof payload.signals !== 'object' || Array.isArray(payload.signals)) {
120
+ return _reject(['INVALID_TYPE:signals']);
121
+ }
122
+ if (typeof payload.classification !== 'object' || Array.isArray(payload.classification)) {
123
+ return _reject(['INVALID_TYPE:classification']);
124
+ }
125
+
126
+ // Nonce must be a 64-character lowercase hex string (32 bytes)
127
+ if (!/^[0-9a-f]{64}$/.test(payload.nonce)) {
128
+ return _reject(['INVALID_NONCE_FORMAT']);
129
+ }
130
+
131
+ // Timestamp must be a plausible Unix ms value (> year 2020, < year 2100)
132
+ const TS_MIN = 1_577_836_800_000; // 2020-01-01
133
+ const TS_MAX = 4_102_444_800_000; // 2100-01-01
134
+ if (payload.timestamp < TS_MIN || payload.timestamp > TS_MAX) {
135
+ return _reject(['TIMESTAMP_OUT_OF_RANGE']);
136
+ }
137
+
138
+ if (payload.version !== 1) {
139
+ return _reject(['UNSUPPORTED_PROOF_VERSION']);
140
+ }
141
+
142
+ // ── 1. Hash integrity ─────────────────────────────────────────────────────
143
+ // receivedHash must be exactly 64 lowercase hex characters
144
+ if (typeof receivedHash !== 'string' || !/^[0-9a-f]{64}$/.test(receivedHash)) {
145
+ return _reject(['INVALID_HASH_FORMAT']);
146
+ }
147
+ const canonical = canonicalJson(payload);
148
+ const enc = new TextEncoder().encode(canonical);
149
+ const computed = bytesToHex(blake3(enc));
150
+
151
+ if (computed !== receivedHash) {
152
+ return _reject(['HASH_MISMATCH_PAYLOAD_TAMPERED']);
153
+ }
154
+
155
+ // ── 2. Timestamp recency ──────────────────────────────────────────────────
156
+ const now = Date.now();
157
+ const age = now - payload.timestamp;
158
+ if (age > maxAgeMs) {
159
+ valid = false;
160
+ reasons.push(`PROOF_EXPIRED: age=${Math.round(age / 1000)}s, max=${maxAgeMs / 1000}s`);
161
+ }
162
+ if (payload.timestamp > now + clockSkewMs) {
163
+ valid = false;
164
+ reasons.push('PROOF_FROM_FUTURE');
165
+ }
166
+
167
+ // ── 3. Nonce freshness ────────────────────────────────────────────────────
168
+ if (checkNonce) {
169
+ const nonceOk = await checkNonce(payload.nonce);
170
+ if (!nonceOk) {
171
+ valid = false;
172
+ reasons.push('NONCE_INVALID_OR_REPLAYED');
173
+ }
174
+ } else {
175
+ riskFlags.push('NONCE_FRESHNESS_NOT_CHECKED');
176
+ }
177
+
178
+ // ── 4. Jitter score ───────────────────────────────────────────────────────
179
+ const jitterScore = payload.classification?.jitterScore ?? 0;
180
+ if (jitterScore < minJitterScore) {
181
+ valid = false;
182
+ reasons.push(`JITTER_SCORE_TOO_LOW: ${jitterScore} < ${minJitterScore}`);
183
+ }
184
+
185
+ // ── 4b. Dynamic threshold (evidence-proportional gate) ──────────────────
186
+ // The server independently computes the minimum passing score based on how
187
+ // much evidence the proof contains. The client's dynamicThreshold field is
188
+ // NEVER trusted — it is only used for logging/auditing.
189
+ //
190
+ // Logic: a proof with only 50 iterations and no bio/audio faces a higher bar
191
+ // (0.62) than a full 200-iteration proof with phased data (0.50).
192
+ // This makes replay attacks with minimal proofs automatically fail the gate.
193
+ const serverDynamicMin = computeServerDynamicThreshold(payload);
194
+
195
+ // We check the FINAL client score (which includes stage-3 coherence adjustment)
196
+ // if it was included, otherwise fall back to the base jitterScore.
197
+ const finalClientScore = payload.classification?.finalScore ?? jitterScore;
198
+ if (finalClientScore < serverDynamicMin) {
199
+ valid = false;
200
+ reasons.push(
201
+ `DYNAMIC_THRESHOLD_NOT_MET: score=${finalClientScore} < ` +
202
+ `serverMin=${serverDynamicMin} (evidenceWeight=${
203
+ _computeEvidenceWeight(payload).toFixed(3)
204
+ })`
205
+ );
206
+ }
207
+
208
+ // Surface diagnostic flags from the client's classifier
209
+ for (const flag of (payload.classification?.flags ?? [])) {
210
+ if (flag.includes('VM') || flag.includes('FLAT') || flag.includes('SYNTHETIC')) {
211
+ riskFlags.push(`CLIENT_FLAG:${flag}`);
212
+ }
213
+ }
214
+
215
+ // Hard override from the client heuristic engine (stage 2).
216
+ // EJR_PHASE_HARD_KILL fires when the stored entropyJitterRatio is mathematically
217
+ // inconsistent with the stored cold_QE / hot_QE values — proof of tampering.
218
+ // A legitimate SDK running on real hardware never triggers this.
219
+ if (payload.heuristic?.hardOverride === 'vm') {
220
+ valid = false;
221
+ reasons.push(
222
+ `HEURISTIC_HARD_OVERRIDE: stage-2 EJR/QE mathematical contradiction — ` +
223
+ `${(payload.heuristic.coherenceFlags ?? []).join(', ')}`
224
+ );
225
+ }
226
+
227
+ // Hard override from the client coherence stage (stage 3).
228
+ // Second line of defence — catches the same contradiction via a different
229
+ // code path and also catches the phase-trajectory forgery variant.
230
+ if (payload.coherence?.hardOverride === 'vm') {
231
+ valid = false;
232
+ reasons.push(
233
+ `COHERENCE_HARD_OVERRIDE: stage-3 analysis detected a mathematical ` +
234
+ `impossibility — ${(payload.coherence.coherenceFlags ?? []).join(', ')}`
235
+ );
236
+ }
237
+
238
+ // Surface all coherence flags for risk tracking / audit logs
239
+ for (const flag of (payload.heuristic?.coherenceFlags ?? [])) {
240
+ riskFlags.push(`HEURISTIC:${flag}`);
241
+ }
242
+ for (const flag of (payload.coherence?.coherenceFlags ?? [])) {
243
+ riskFlags.push(`COHERENCE:${flag}`);
244
+ }
245
+
246
+ // ── 5. Canvas / WebGL renderer check ──────────────────────────────────────
247
+ const canvas = payload.signals?.canvas;
248
+ if (canvas) {
249
+ if (canvas.isSoftwareRenderer && blockSoftwareRenderer) {
250
+ valid = false;
251
+ reasons.push(`SOFTWARE_RENDERER_DETECTED: ${canvas.webglRenderer}`);
252
+ }
253
+ const rendererLc = (canvas.webglRenderer ?? '').toLowerCase();
254
+ for (const pattern of VM_RENDERER_BLOCKLIST) {
255
+ if (rendererLc.includes(pattern)) {
256
+ valid = false;
257
+ reasons.push(`BLOCKLISTED_RENDERER: ${canvas.webglRenderer}`);
258
+ riskFlags.push(`RENDERER_MATCH:${pattern}`);
259
+ break;
260
+ }
261
+ }
262
+ if (!canvas.available) {
263
+ riskFlags.push('CANVAS_UNAVAILABLE');
264
+ }
265
+ }
266
+
267
+ // ── 6. Bio activity ───────────────────────────────────────────────────────
268
+ const bio = payload.signals?.bio;
269
+ if (bio) {
270
+ if (requireBio && !bio.hasActivity) {
271
+ valid = false;
272
+ reasons.push('NO_BIO_ACTIVITY_DETECTED');
273
+ }
274
+ if (bio.mouseSampleCount === 0 && bio.keyboardSampleCount === 0) {
275
+ riskFlags.push('ZERO_BIO_SAMPLES');
276
+ }
277
+ // Interference coefficient check: real human+hardware shows measurable correlation
278
+ if (bio.interferenceCoefficient < -0.3) {
279
+ riskFlags.push('NEGATIVE_INTERFERENCE_COEFFICIENT');
280
+ }
281
+ }
282
+
283
+ // ── 7. Internal consistency checks ────────────────────────────────────────
284
+ const entropy = payload.signals?.entropy;
285
+ if (entropy) {
286
+ // CV and jitter score should be directionally consistent
287
+ if (entropy.timingsCV < 0.01 && jitterScore > 0.7) {
288
+ riskFlags.push('INCONSISTENCY:LOW_CV_BUT_HIGH_SCORE');
289
+ }
290
+ // Timer granularity should not be exactly 0 (no real device has infinite resolution)
291
+ if (entropy.timerGranularityMs === 0) {
292
+ riskFlags.push('SUSPICIOUS_ZERO_TIMER_GRANULARITY');
293
+ }
294
+ // Extreme thermal patterns inconsistent with score
295
+ if (entropy.thermalPattern === 'flat' && jitterScore > 0.8) {
296
+ riskFlags.push('INCONSISTENCY:FLAT_THERMAL_BUT_HIGH_SCORE');
297
+ }
298
+ // Hurst exponent way out of range
299
+ if (entropy.hurstExponent != null) {
300
+ if (entropy.hurstExponent < 0.2 || entropy.hurstExponent > 0.85) {
301
+ riskFlags.push(`EXTREME_HURST:${entropy.hurstExponent}`);
302
+ }
303
+ }
304
+ }
305
+
306
+ // ── 7b. Cross-signal physics forgery detection ────────────────────────────
307
+ // BLAKE3 prevents tampering with a payload that was legitimately generated by
308
+ // the SDK. However, a determined attacker can:
309
+ // 1. Obtain a valid server nonce
310
+ // 2. Craft a fake payload with forged statistics
311
+ // 3. Compute BLAKE3(forgedPayload) themselves (BLAKE3 is public)
312
+ // 4. Submit { payload: forgedPayload, hash: selfComputedHash }
313
+ //
314
+ // These checks detect statistically impossible metric combinations that no
315
+ // real device would ever produce, catching crafted payloads even though the
316
+ // hash integrity check passes.
317
+ //
318
+ // All three thresholds are set conservatively: they only fire when the
319
+ // combination is physically IMPOSSIBLE, not just unlikely, to avoid false
320
+ // positives on unusual-but-legitimate hardware.
321
+ if (entropy) {
322
+ const cv = entropy.timingsCV ?? null;
323
+ const qe = entropy.quantizationEntropy ?? null;
324
+ const lag1 = entropy.autocorr_lag1 ?? null;
325
+
326
+ // Impossibly flat CV + high physical score
327
+ // Real explanation: CV < 0.015 means timing jitter < 1.5% — hypervisor-flat.
328
+ // No real-silicon CPU running a WASM matrix multiply achieves this.
329
+ // A high jitterScore (> 0.65) is physically incompatible with CV < 0.015.
330
+ if (cv !== null && cv < 0.015 && jitterScore > 0.65) {
331
+ valid = false;
332
+ reasons.push(
333
+ `FORGED_SIGNAL:CV_SCORE_IMPOSSIBLE cv=${cv.toFixed(5)} is hypervisor-flat ` +
334
+ `but jitterScore=${jitterScore.toFixed(3)} claims physical hardware`
335
+ );
336
+ }
337
+
338
+ // VM-grade autocorrelation + high physical score
339
+ // lag1 > 0.70 is a hypervisor scheduler rhythm — unambiguous VM signature.
340
+ // A device with that level of autocorrelation cannot score > 0.70 on the
341
+ // physical scale; the jitter classifier would have penalised it heavily.
342
+ if (lag1 !== null && lag1 > 0.70 && jitterScore > 0.70) {
343
+ valid = false;
344
+ reasons.push(
345
+ `FORGED_SIGNAL:AUTOCORR_SCORE_IMPOSSIBLE lag1=${lag1.toFixed(3)} is VM-level ` +
346
+ `but jitterScore=${jitterScore.toFixed(3)} claims physical hardware`
347
+ );
348
+ }
349
+
350
+ // VM-grade quantization entropy + high physical score
351
+ // QE < 2.0 means timings cluster on a small number of distinct values —
352
+ // the classic integer-millisecond quantisation of an emulated/virtual timer.
353
+ // A device producing QE < 2.0 cannot legitimately score > 0.65 as physical.
354
+ if (qe !== null && qe < 2.0 && jitterScore > 0.65) {
355
+ valid = false;
356
+ reasons.push(
357
+ `FORGED_SIGNAL:QE_SCORE_IMPOSSIBLE qe=${qe.toFixed(3)} bits is VM-level ` +
358
+ `but jitterScore=${jitterScore.toFixed(3)} claims physical hardware`
359
+ );
360
+ }
361
+ }
362
+
363
+ // ── 8. Audio signal check ─────────────────────────────────────────────────
364
+ const audio = payload.signals?.audio;
365
+ if (audio?.available) {
366
+ // Impossibly low jitter CV may indicate a synthetic audio driver
367
+ if (audio.callbackJitterCV < 0.001) {
368
+ riskFlags.push('AUDIO_JITTER_TOO_FLAT');
369
+ }
370
+ }
371
+
372
+ // ── Confidence rating ─────────────────────────────────────────────────────
373
+ let confidence;
374
+ if (!valid) {
375
+ confidence = 'rejected';
376
+ } else if (riskFlags.length === 0 && jitterScore >= 0.75) {
377
+ confidence = 'high';
378
+ } else if (riskFlags.length <= 2 && jitterScore >= 0.60) {
379
+ confidence = 'medium';
380
+ } else {
381
+ confidence = 'low';
382
+ }
383
+
384
+ return {
385
+ valid,
386
+ score: jitterScore,
387
+ confidence,
388
+ reasons,
389
+ riskFlags,
390
+ meta: {
391
+ receivedAt: now,
392
+ proofAge: age,
393
+ jitterScore,
394
+ canvasRenderer: canvas?.webglRenderer ?? null,
395
+ bioActivity: bio?.hasActivity ?? false,
396
+ },
397
+ };
398
+ }
399
+
400
+ /**
401
+ * @typedef {object} ValidationResult
402
+ * @property {boolean} valid
403
+ * @property {number} score
404
+ * @property {'high'|'medium'|'low'|'rejected'} confidence
405
+ * @property {string[]} reasons - human-readable rejection reasons
406
+ * @property {string[]} riskFlags - non-blocking risk indicators
407
+ * @property {object} meta
408
+ */
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // generateNonce (convenience helper for the server challenge flow)
412
+ // ---------------------------------------------------------------------------
413
+
414
+ /**
415
+ * Generate a cryptographically random 32-byte nonce for the server challenge.
416
+ * The server should store this nonce with a TTL before issuing it to the client.
417
+ *
418
+ * @returns {string} hex nonce
419
+ */
420
+ export async function generateNonce() {
421
+ const buf = new Uint8Array(32);
422
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
423
+ // Browser + Node.js ≥ 19
424
+ globalThis.crypto.getRandomValues(buf);
425
+ } else {
426
+ // Node.js 18 — webcrypto is at `crypto.webcrypto`
427
+ const { webcrypto } = await import('node:crypto');
428
+ webcrypto.getRandomValues(buf);
429
+ }
430
+ return bytesToHex(buf);
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Internal helpers
435
+ // ---------------------------------------------------------------------------
436
+
437
+ function _reject(reasons) {
438
+ return {
439
+ valid: false,
440
+ score: 0,
441
+ confidence: 'rejected',
442
+ reasons,
443
+ riskFlags: [],
444
+ meta: {},
445
+ };
446
+ }
447
+
448
+ function _computeEvidenceWeight(payload) {
449
+ const n = payload?.signals?.entropy?.iterations ?? 0;
450
+ const hasPhases = payload?.heuristic?.entropyJitterRatio != null;
451
+ const hasBio = payload?.signals?.bio?.hasActivity === true;
452
+ const hasAudio = payload?.signals?.audio?.available === true;
453
+ const hasCanvas = payload?.signals?.canvas?.available === true;
454
+ return Math.min(1.0,
455
+ Math.min(1.0, n / 200) * 0.65 +
456
+ (hasPhases ? 0.15 : 0) +
457
+ (hasBio ? 0.10 : 0) +
458
+ (hasAudio ? 0.05 : 0) +
459
+ (hasCanvas ? 0.05 : 0)
460
+ );
461
+ }