@svrnsec/pulse 0.7.0 → 0.9.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/LICENSE +21 -21
- package/README.md +883 -782
- package/SECURITY.md +27 -22
- package/bin/svrnsec-pulse.js +7 -7
- package/dist/{pulse.cjs.js → pulse.cjs} +6428 -6413
- package/dist/pulse.cjs.map +1 -0
- package/dist/pulse.esm.js +6429 -6415
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +949 -846
- package/package.json +189 -184
- package/pkg/pulse_core.js +174 -173
- package/src/analysis/audio.js +213 -213
- package/src/analysis/authenticityAudit.js +408 -393
- package/src/analysis/coherence.js +502 -502
- package/src/analysis/coordinatedBehavior.js +825 -804
- package/src/analysis/heuristic.js +428 -428
- package/src/analysis/jitter.js +446 -446
- package/src/analysis/llm.js +473 -472
- package/src/analysis/populationEntropy.js +404 -403
- package/src/analysis/provider.js +248 -248
- package/src/analysis/refraction.js +392 -391
- package/src/analysis/trustScore.js +356 -356
- package/src/cli/args.js +36 -36
- package/src/cli/commands/scan.js +192 -192
- package/src/cli/runner.js +157 -157
- package/src/collector/adaptive.js +200 -200
- package/src/collector/bio.js +297 -287
- package/src/collector/canvas.js +247 -239
- package/src/collector/dram.js +203 -203
- package/src/collector/enf.js +311 -311
- package/src/collector/entropy.js +195 -195
- package/src/collector/gpu.js +248 -245
- package/src/collector/idleAttestation.js +480 -480
- package/src/collector/sabTimer.js +189 -191
- package/src/errors.js +54 -0
- package/src/fingerprint.js +475 -475
- package/src/index.js +345 -342
- package/src/integrations/react-native.js +462 -459
- package/src/integrations/react.js +184 -185
- package/src/middleware/express.js +155 -155
- package/src/middleware/next.js +174 -175
- package/src/proof/challenge.js +249 -249
- package/src/proof/engagementToken.js +426 -394
- package/src/proof/fingerprint.js +268 -268
- package/src/proof/validator.js +82 -142
- package/src/registry/serializer.js +349 -349
- package/src/terminal.js +263 -263
- package/src/update-notifier.js +259 -264
- package/dist/pulse.cjs.js.map +0 -1
package/src/proof/validator.js
CHANGED
|
@@ -2,79 +2,74 @@
|
|
|
2
2
|
* @svrnsec/pulse — Server-Side Validator
|
|
3
3
|
*
|
|
4
4
|
* Verifies a ProofPayload + BLAKE3 commitment received from the client.
|
|
5
|
-
* This module is for NODE.JS / SERVER use only.
|
|
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.
|
|
5
|
+
* This module is for NODE.JS / SERVER use only.
|
|
21
6
|
*/
|
|
22
7
|
|
|
23
8
|
import { blake3 } from '@noble/hashes/blake3';
|
|
24
9
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
25
|
-
import { randomFillSync } from 'node:crypto';
|
|
10
|
+
import { randomFillSync, timingSafeEqual } from 'node:crypto';
|
|
26
11
|
import { canonicalJson } from './fingerprint.js';
|
|
27
12
|
import { computeServerDynamicThreshold } from '../analysis/coherence.js';
|
|
28
13
|
|
|
29
14
|
// ---------------------------------------------------------------------------
|
|
30
|
-
// Known software / virtual renderer
|
|
15
|
+
// Known software / virtual renderer patterns
|
|
31
16
|
// ---------------------------------------------------------------------------
|
|
32
17
|
const VM_RENDERER_BLOCKLIST = [
|
|
33
|
-
// Software / virtual renderers
|
|
34
18
|
'llvmpipe', 'swiftshader', 'softpipe', 'mesa offscreen',
|
|
35
19
|
'microsoft basic render', 'vmware svga', 'vmware', 'virtualbox',
|
|
36
20
|
'parallels', 'chromium swiftshader', 'google swiftshader',
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
'nvidia
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'nvidia h100', // Hopper — datacenter only
|
|
43
|
-
'nvidia h200', // Hopper successor — datacenter only
|
|
44
|
-
'nvidia b100', // Blackwell — datacenter only
|
|
45
|
-
'nvidia b200', // Blackwell Ultra — datacenter only
|
|
46
|
-
'nvidia gh200', // Grace-Hopper superchip
|
|
47
|
-
// AMD datacenter / HPC — no consumer has these
|
|
48
|
-
'amd instinct', // covers mi100, mi200, mi250, mi300 family
|
|
49
|
-
'amd mi300',
|
|
50
|
-
'amd mi250',
|
|
51
|
-
'amd mi200',
|
|
52
|
-
// Cloud-specific AI accelerators
|
|
53
|
-
'aws inferentia',
|
|
54
|
-
'aws trainium',
|
|
55
|
-
'google tpu',
|
|
21
|
+
'cirrussm', 'qxl', 'virtio', 'bochs',
|
|
22
|
+
'nvidia t4', 'nvidia a10g', 'nvidia a100', 'nvidia h100',
|
|
23
|
+
'nvidia h200', 'nvidia b100', 'nvidia b200', 'nvidia gh200',
|
|
24
|
+
'amd instinct', 'amd mi300', 'amd mi250', 'amd mi200',
|
|
25
|
+
'aws inferentia', 'aws trainium', 'google tpu',
|
|
56
26
|
];
|
|
57
27
|
|
|
28
|
+
// ANGLE with software backend — match only software variants, not real hardware through ANGLE
|
|
29
|
+
const VM_RENDERER_REGEX = [
|
|
30
|
+
/angle\s*\(.*software/i,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Recursive prototype pollution guard
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function _checkProtoPollution(obj, depth = 0) {
|
|
37
|
+
if (depth > 10 || obj === null || typeof obj !== 'object') return false;
|
|
38
|
+
if (Array.isArray(obj)) {
|
|
39
|
+
for (const item of obj) {
|
|
40
|
+
if (_checkProtoPollution(item, depth + 1)) return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
Object.prototype.hasOwnProperty.call(obj, '__proto__') ||
|
|
46
|
+
Object.prototype.hasOwnProperty.call(obj, 'constructor') ||
|
|
47
|
+
Object.prototype.hasOwnProperty.call(obj, 'prototype')
|
|
48
|
+
) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
for (const key of Object.keys(obj)) {
|
|
52
|
+
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
|
53
|
+
if (_checkProtoPollution(obj[key], depth + 1)) return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
58
59
|
// ---------------------------------------------------------------------------
|
|
59
60
|
// validateProof
|
|
60
61
|
// ---------------------------------------------------------------------------
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
|
-
* Validates a client-submitted proof.
|
|
64
|
-
*
|
|
65
64
|
* @param {import('./fingerprint.js').ProofPayload} payload
|
|
66
|
-
* @param {string} receivedHash
|
|
65
|
+
* @param {string} receivedHash
|
|
67
66
|
* @param {object} [opts]
|
|
68
|
-
* @param {number} [opts.minJitterScore=0.55]
|
|
69
|
-
* @param {number} [opts.maxAgeMs=300_000]
|
|
70
|
-
* @param {number} [opts.clockSkewMs=30_000]
|
|
71
|
-
* @param {boolean} [opts.requireBio=false]
|
|
72
|
-
* @param {boolean} [opts.blockSoftwareRenderer=true]
|
|
73
|
-
* @param {Function} [opts.checkNonce]
|
|
74
|
-
* Called to verify the nonce was issued by this server and not yet consumed.
|
|
75
|
-
* Should mark the nonce as consumed atomically (e.g. Redis SET NX with TTL).
|
|
76
|
-
* If omitted, nonce freshness is NOT checked (not recommended for production).
|
|
77
|
-
*
|
|
67
|
+
* @param {number} [opts.minJitterScore=0.55]
|
|
68
|
+
* @param {number} [opts.maxAgeMs=300_000]
|
|
69
|
+
* @param {number} [opts.clockSkewMs=30_000]
|
|
70
|
+
* @param {boolean} [opts.requireBio=false]
|
|
71
|
+
* @param {boolean} [opts.blockSoftwareRenderer=true]
|
|
72
|
+
* @param {Function} [opts.checkNonce]
|
|
78
73
|
* @returns {Promise<ValidationResult>}
|
|
79
74
|
*/
|
|
80
75
|
export async function validateProof(payload, receivedHash, opts = {}) {
|
|
@@ -96,12 +91,8 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
96
91
|
return _reject(['INVALID_PAYLOAD_STRUCTURE']);
|
|
97
92
|
}
|
|
98
93
|
|
|
99
|
-
//
|
|
100
|
-
if (
|
|
101
|
-
Object.prototype.hasOwnProperty.call(payload, '__proto__') ||
|
|
102
|
-
Object.prototype.hasOwnProperty.call(payload, 'constructor') ||
|
|
103
|
-
Object.prototype.hasOwnProperty.call(payload, 'prototype')
|
|
104
|
-
) {
|
|
94
|
+
// Recursive prototype pollution guard — checks all nested objects
|
|
95
|
+
if (_checkProtoPollution(payload)) {
|
|
105
96
|
return _reject(['PROTOTYPE_POLLUTION_ATTEMPT']);
|
|
106
97
|
}
|
|
107
98
|
|
|
@@ -113,7 +104,7 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
113
104
|
}
|
|
114
105
|
}
|
|
115
106
|
|
|
116
|
-
// Type assertions
|
|
107
|
+
// Type assertions
|
|
117
108
|
if (typeof payload.version !== 'number') return _reject(['INVALID_TYPE:version']);
|
|
118
109
|
if (typeof payload.timestamp !== 'number') return _reject(['INVALID_TYPE:timestamp']);
|
|
119
110
|
if (typeof payload.nonce !== 'string') return _reject(['INVALID_TYPE:nonce']);
|
|
@@ -124,14 +115,8 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
124
115
|
return _reject(['INVALID_TYPE:classification']);
|
|
125
116
|
}
|
|
126
117
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// function (if supplied) should perform any format validation it requires
|
|
130
|
-
// and return false for invalid or replayed nonces.
|
|
131
|
-
|
|
132
|
-
// Timestamp must be a plausible Unix ms value (> year 2020, < year 2100)
|
|
133
|
-
const TS_MIN = 1_577_836_800_000; // 2020-01-01
|
|
134
|
-
const TS_MAX = 4_102_444_800_000; // 2100-01-01
|
|
118
|
+
const TS_MIN = 1_577_836_800_000;
|
|
119
|
+
const TS_MAX = 4_102_444_800_000;
|
|
135
120
|
if (payload.timestamp < TS_MIN || payload.timestamp > TS_MAX) {
|
|
136
121
|
return _reject(['TIMESTAMP_OUT_OF_RANGE']);
|
|
137
122
|
}
|
|
@@ -140,8 +125,7 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
140
125
|
return _reject(['UNSUPPORTED_PROOF_VERSION']);
|
|
141
126
|
}
|
|
142
127
|
|
|
143
|
-
// ── 1. Hash integrity
|
|
144
|
-
// receivedHash must be exactly 64 lowercase hex characters
|
|
128
|
+
// ── 1. Hash integrity (timing-safe comparison) ────────────────────────────
|
|
145
129
|
if (typeof receivedHash !== 'string' || !/^[0-9a-f]{64}$/.test(receivedHash)) {
|
|
146
130
|
return _reject(['INVALID_HASH_FORMAT']);
|
|
147
131
|
}
|
|
@@ -149,7 +133,13 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
149
133
|
const enc = new TextEncoder().encode(canonical);
|
|
150
134
|
const computed = bytesToHex(blake3(enc));
|
|
151
135
|
|
|
152
|
-
|
|
136
|
+
try {
|
|
137
|
+
const computedBuf = Buffer.from(computed, 'hex');
|
|
138
|
+
const receivedBuf = Buffer.from(receivedHash, 'hex');
|
|
139
|
+
if (computedBuf.length !== receivedBuf.length || !timingSafeEqual(computedBuf, receivedBuf)) {
|
|
140
|
+
return _reject(['HASH_MISMATCH_PAYLOAD_TAMPERED']);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
153
143
|
return _reject(['HASH_MISMATCH_PAYLOAD_TAMPERED']);
|
|
154
144
|
}
|
|
155
145
|
|
|
@@ -166,7 +156,9 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
166
156
|
}
|
|
167
157
|
|
|
168
158
|
// ── 3. Nonce freshness ────────────────────────────────────────────────────
|
|
159
|
+
let nonceChecked = false;
|
|
169
160
|
if (checkNonce) {
|
|
161
|
+
nonceChecked = true;
|
|
170
162
|
const nonceOk = await checkNonce(payload.nonce);
|
|
171
163
|
if (!nonceOk) {
|
|
172
164
|
valid = false;
|
|
@@ -183,23 +175,12 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
183
175
|
reasons.push(`JITTER_SCORE_TOO_LOW: ${jitterScore} < ${minJitterScore}`);
|
|
184
176
|
}
|
|
185
177
|
|
|
186
|
-
// ── 4b. Dynamic threshold
|
|
187
|
-
// The server independently computes the minimum passing score based on how
|
|
188
|
-
// much evidence the proof contains. The client's dynamicThreshold field is
|
|
189
|
-
// NEVER trusted — it is only used for logging/auditing.
|
|
190
|
-
//
|
|
191
|
-
// Logic: a proof with only 50 iterations and no bio/audio faces a higher bar
|
|
192
|
-
// (0.62) than a full 200-iteration proof with phased data (0.50).
|
|
193
|
-
// This makes replay attacks with minimal proofs automatically fail the gate.
|
|
178
|
+
// ── 4b. Dynamic threshold — server recomputes from raw signals, not client finalScore
|
|
194
179
|
const serverDynamicMin = computeServerDynamicThreshold(payload);
|
|
195
|
-
|
|
196
|
-
// We check the FINAL client score (which includes stage-3 coherence adjustment)
|
|
197
|
-
// if it was included, otherwise fall back to the base jitterScore.
|
|
198
|
-
const finalClientScore = payload.classification?.finalScore ?? jitterScore;
|
|
199
|
-
if (finalClientScore < serverDynamicMin) {
|
|
180
|
+
if (jitterScore < serverDynamicMin) {
|
|
200
181
|
valid = false;
|
|
201
182
|
reasons.push(
|
|
202
|
-
`DYNAMIC_THRESHOLD_NOT_MET:
|
|
183
|
+
`DYNAMIC_THRESHOLD_NOT_MET: jitterScore=${jitterScore} < ` +
|
|
203
184
|
`serverMin=${serverDynamicMin} (evidenceWeight=${
|
|
204
185
|
_computeEvidenceWeight(payload).toFixed(3)
|
|
205
186
|
})`
|
|
@@ -213,10 +194,7 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
213
194
|
}
|
|
214
195
|
}
|
|
215
196
|
|
|
216
|
-
// Hard override from
|
|
217
|
-
// EJR_PHASE_HARD_KILL fires when the stored entropyJitterRatio is mathematically
|
|
218
|
-
// inconsistent with the stored cold_QE / hot_QE values — proof of tampering.
|
|
219
|
-
// A legitimate SDK running on real hardware never triggers this.
|
|
197
|
+
// Hard override from heuristic engine (stage 2)
|
|
220
198
|
if (payload.heuristic?.hardOverride === 'vm') {
|
|
221
199
|
valid = false;
|
|
222
200
|
reasons.push(
|
|
@@ -225,9 +203,7 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
225
203
|
);
|
|
226
204
|
}
|
|
227
205
|
|
|
228
|
-
// Hard override from
|
|
229
|
-
// Second line of defence — catches the same contradiction via a different
|
|
230
|
-
// code path and also catches the phase-trajectory forgery variant.
|
|
206
|
+
// Hard override from coherence stage (stage 3)
|
|
231
207
|
if (payload.coherence?.hardOverride === 'vm') {
|
|
232
208
|
valid = false;
|
|
233
209
|
reasons.push(
|
|
@@ -236,7 +212,7 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
236
212
|
);
|
|
237
213
|
}
|
|
238
214
|
|
|
239
|
-
// Surface all coherence flags for risk tracking
|
|
215
|
+
// Surface all coherence flags for risk tracking
|
|
240
216
|
for (const flag of (payload.heuristic?.coherenceFlags ?? [])) {
|
|
241
217
|
riskFlags.push(`HEURISTIC:${flag}`);
|
|
242
218
|
}
|
|
@@ -252,6 +228,7 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
252
228
|
reasons.push(`SOFTWARE_RENDERER_DETECTED: ${canvas.webglRenderer}`);
|
|
253
229
|
}
|
|
254
230
|
const rendererLc = (canvas.webglRenderer ?? '').toLowerCase();
|
|
231
|
+
// Check substring patterns
|
|
255
232
|
for (const pattern of VM_RENDERER_BLOCKLIST) {
|
|
256
233
|
if (rendererLc.includes(pattern)) {
|
|
257
234
|
valid = false;
|
|
@@ -260,6 +237,15 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
260
237
|
break;
|
|
261
238
|
}
|
|
262
239
|
}
|
|
240
|
+
// Check regex patterns (e.g. ANGLE software)
|
|
241
|
+
for (const re of VM_RENDERER_REGEX) {
|
|
242
|
+
if (re.test(rendererLc)) {
|
|
243
|
+
valid = false;
|
|
244
|
+
reasons.push(`BLOCKLISTED_RENDERER: ${canvas.webglRenderer}`);
|
|
245
|
+
riskFlags.push('RENDERER_MATCH:angle_software');
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
263
249
|
if (!canvas.available) {
|
|
264
250
|
riskFlags.push('CANVAS_UNAVAILABLE');
|
|
265
251
|
}
|
|
@@ -275,7 +261,6 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
275
261
|
if (bio.mouseSampleCount === 0 && bio.keyboardSampleCount === 0) {
|
|
276
262
|
riskFlags.push('ZERO_BIO_SAMPLES');
|
|
277
263
|
}
|
|
278
|
-
// Interference coefficient check: real human+hardware shows measurable correlation
|
|
279
264
|
if (bio.interferenceCoefficient < -0.3) {
|
|
280
265
|
riskFlags.push('NEGATIVE_INTERFERENCE_COEFFICIENT');
|
|
281
266
|
}
|
|
@@ -284,19 +269,15 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
284
269
|
// ── 7. Internal consistency checks ────────────────────────────────────────
|
|
285
270
|
const entropy = payload.signals?.entropy;
|
|
286
271
|
if (entropy) {
|
|
287
|
-
// CV and jitter score should be directionally consistent
|
|
288
272
|
if (entropy.timingsCV < 0.01 && jitterScore > 0.7) {
|
|
289
273
|
riskFlags.push('INCONSISTENCY:LOW_CV_BUT_HIGH_SCORE');
|
|
290
274
|
}
|
|
291
|
-
// Timer granularity should not be exactly 0 (no real device has infinite resolution)
|
|
292
275
|
if (entropy.timerGranularityMs === 0) {
|
|
293
276
|
riskFlags.push('SUSPICIOUS_ZERO_TIMER_GRANULARITY');
|
|
294
277
|
}
|
|
295
|
-
// Extreme thermal patterns inconsistent with score
|
|
296
278
|
if (entropy.thermalPattern === 'flat' && jitterScore > 0.8) {
|
|
297
279
|
riskFlags.push('INCONSISTENCY:FLAT_THERMAL_BUT_HIGH_SCORE');
|
|
298
280
|
}
|
|
299
|
-
// Hurst exponent way out of range
|
|
300
281
|
if (entropy.hurstExponent != null) {
|
|
301
282
|
if (entropy.hurstExponent < 0.2 || entropy.hurstExponent > 0.85) {
|
|
302
283
|
riskFlags.push(`EXTREME_HURST:${entropy.hurstExponent}`);
|
|
@@ -305,29 +286,11 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
305
286
|
}
|
|
306
287
|
|
|
307
288
|
// ── 7b. Cross-signal physics forgery detection ────────────────────────────
|
|
308
|
-
// BLAKE3 prevents tampering with a payload that was legitimately generated by
|
|
309
|
-
// the SDK. However, a determined attacker can:
|
|
310
|
-
// 1. Obtain a valid server nonce
|
|
311
|
-
// 2. Craft a fake payload with forged statistics
|
|
312
|
-
// 3. Compute BLAKE3(forgedPayload) themselves (BLAKE3 is public)
|
|
313
|
-
// 4. Submit { payload: forgedPayload, hash: selfComputedHash }
|
|
314
|
-
//
|
|
315
|
-
// These checks detect statistically impossible metric combinations that no
|
|
316
|
-
// real device would ever produce, catching crafted payloads even though the
|
|
317
|
-
// hash integrity check passes.
|
|
318
|
-
//
|
|
319
|
-
// All three thresholds are set conservatively: they only fire when the
|
|
320
|
-
// combination is physically IMPOSSIBLE, not just unlikely, to avoid false
|
|
321
|
-
// positives on unusual-but-legitimate hardware.
|
|
322
289
|
if (entropy) {
|
|
323
290
|
const cv = entropy.timingsCV ?? null;
|
|
324
291
|
const qe = entropy.quantizationEntropy ?? null;
|
|
325
292
|
const lag1 = entropy.autocorr_lag1 ?? null;
|
|
326
293
|
|
|
327
|
-
// Impossibly flat CV + high physical score
|
|
328
|
-
// Real explanation: CV < 0.015 means timing jitter < 1.5% — hypervisor-flat.
|
|
329
|
-
// No real-silicon CPU running a WASM matrix multiply achieves this.
|
|
330
|
-
// A high jitterScore (> 0.65) is physically incompatible with CV < 0.015.
|
|
331
294
|
if (cv !== null && cv < 0.015 && jitterScore > 0.65) {
|
|
332
295
|
valid = false;
|
|
333
296
|
reasons.push(
|
|
@@ -336,10 +299,6 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
336
299
|
);
|
|
337
300
|
}
|
|
338
301
|
|
|
339
|
-
// VM-grade autocorrelation + high physical score
|
|
340
|
-
// lag1 > 0.70 is a hypervisor scheduler rhythm — unambiguous VM signature.
|
|
341
|
-
// A device with that level of autocorrelation cannot score > 0.70 on the
|
|
342
|
-
// physical scale; the jitter classifier would have penalised it heavily.
|
|
343
302
|
if (lag1 !== null && lag1 > 0.70 && jitterScore > 0.70) {
|
|
344
303
|
valid = false;
|
|
345
304
|
reasons.push(
|
|
@@ -348,10 +307,6 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
348
307
|
);
|
|
349
308
|
}
|
|
350
309
|
|
|
351
|
-
// VM-grade quantization entropy + high physical score
|
|
352
|
-
// QE < 2.0 means timings cluster on a small number of distinct values —
|
|
353
|
-
// the classic integer-millisecond quantisation of an emulated/virtual timer.
|
|
354
|
-
// A device producing QE < 2.0 cannot legitimately score > 0.65 as physical.
|
|
355
310
|
if (qe !== null && qe < 2.0 && jitterScore > 0.65) {
|
|
356
311
|
valid = false;
|
|
357
312
|
reasons.push(
|
|
@@ -364,7 +319,6 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
364
319
|
// ── 8. Audio signal check ─────────────────────────────────────────────────
|
|
365
320
|
const audio = payload.signals?.audio;
|
|
366
321
|
if (audio?.available) {
|
|
367
|
-
// Impossibly low jitter CV may indicate a synthetic audio driver
|
|
368
322
|
if (audio.callbackJitterCV < 0.001) {
|
|
369
323
|
riskFlags.push('AUDIO_JITTER_TOO_FLAT');
|
|
370
324
|
}
|
|
@@ -374,6 +328,9 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
374
328
|
let confidence;
|
|
375
329
|
if (!valid) {
|
|
376
330
|
confidence = 'rejected';
|
|
331
|
+
} else if (!nonceChecked) {
|
|
332
|
+
// Without nonce verification, confidence cannot be higher than 'low'
|
|
333
|
+
confidence = 'low';
|
|
377
334
|
} else if (riskFlags.length === 0 && jitterScore >= 0.75) {
|
|
378
335
|
confidence = 'high';
|
|
379
336
|
} else if (riskFlags.length <= 2 && jitterScore >= 0.60) {
|
|
@@ -398,30 +355,13 @@ export async function validateProof(payload, receivedHash, opts = {}) {
|
|
|
398
355
|
};
|
|
399
356
|
}
|
|
400
357
|
|
|
401
|
-
/**
|
|
402
|
-
* @typedef {object} ValidationResult
|
|
403
|
-
* @property {boolean} valid
|
|
404
|
-
* @property {number} score
|
|
405
|
-
* @property {'high'|'medium'|'low'|'rejected'} confidence
|
|
406
|
-
* @property {string[]} reasons - human-readable rejection reasons
|
|
407
|
-
* @property {string[]} riskFlags - non-blocking risk indicators
|
|
408
|
-
* @property {object} meta
|
|
409
|
-
*/
|
|
358
|
+
/** @typedef {object} ValidationResult */
|
|
410
359
|
|
|
411
360
|
// ---------------------------------------------------------------------------
|
|
412
|
-
// generateNonce
|
|
361
|
+
// generateNonce
|
|
413
362
|
// ---------------------------------------------------------------------------
|
|
414
363
|
|
|
415
|
-
/**
|
|
416
|
-
* Generate a cryptographically random 32-byte nonce for the server challenge.
|
|
417
|
-
* The server should store this nonce with a TTL before issuing it to the client.
|
|
418
|
-
*
|
|
419
|
-
* @returns {string} hex nonce
|
|
420
|
-
*/
|
|
421
364
|
export function generateNonce() {
|
|
422
|
-
// Synchronous nonce generator for server-side use and tests.
|
|
423
|
-
// Prefer global crypto.getRandomValues when available; otherwise use
|
|
424
|
-
// Node's `randomFillSync` which is synchronous and available in Node.
|
|
425
365
|
let buf;
|
|
426
366
|
if (typeof globalThis.crypto?.getRandomValues === 'function') {
|
|
427
367
|
buf = new Uint8Array(32);
|