@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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/SECURITY.md +86 -0
- package/dist/pulse.cjs.js +4906 -0
- package/dist/pulse.cjs.js.map +1 -0
- package/dist/pulse.esm.js +4898 -0
- package/dist/pulse.esm.js.map +1 -0
- package/index.d.ts +588 -0
- package/package.json +93 -0
- package/pkg/pulse_core.js +173 -0
- package/src/integrations/react.js +185 -0
- package/src/middleware/express.js +155 -0
- package/src/middleware/next.js +175 -0
- package/src/proof/fingerprint.js +212 -0
- package/src/proof/validator.js +461 -0
- package/src/registry/serializer.js +349 -0
|
@@ -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
|
+
}
|