@svrnsec/pulse 0.6.0 → 0.8.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 -622
- package/SECURITY.md +86 -86
- package/bin/svrnsec-pulse.js +7 -7
- package/dist/{pulse.cjs.js → pulse.cjs} +6379 -6420
- package/dist/pulse.cjs.map +1 -0
- package/dist/pulse.esm.js +6380 -6421
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +895 -846
- package/package.json +185 -165
- package/pkg/pulse_core.js +174 -173
- package/src/analysis/audio.js +213 -213
- package/src/analysis/authenticityAudit.js +408 -390
- package/src/analysis/coherence.js +502 -502
- package/src/analysis/coordinatedBehavior.js +825 -0
- 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 -0
- 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/fingerprint.js +475 -475
- package/src/index.js +342 -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 +83 -143
- 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
|
@@ -1,349 +1,349 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @
|
|
3
|
-
*
|
|
4
|
-
* The registry is a crowdsourced database of device "Silicon Signatures" —
|
|
5
|
-
* compact, privacy-safe profiles that characterise a device class rather than
|
|
6
|
-
* an individual device. Developers submit signatures from their test hardware
|
|
7
|
-
* to help calibrate the classifier for hardware we haven't seen yet.
|
|
8
|
-
*
|
|
9
|
-
* A signature captures:
|
|
10
|
-
* - The statistical "shape" of the timing distribution (not raw timings)
|
|
11
|
-
* - The provider/hypervisor classification
|
|
12
|
-
* - The timing profile type (analog-fog vs picket-fence)
|
|
13
|
-
* - Hardware generation hints (GPU vendor, renderer class)
|
|
14
|
-
*
|
|
15
|
-
* What a signature does NOT capture:
|
|
16
|
-
* - Any individual timing values
|
|
17
|
-
* - Canvas pixel data
|
|
18
|
-
* - Mouse coordinates or keystrokes
|
|
19
|
-
* - Any user-identifiable information
|
|
20
|
-
*
|
|
21
|
-
* Usage:
|
|
22
|
-
* import { serializeSignature, matchRegistry, KNOWN_PROFILES } from '@
|
|
23
|
-
*
|
|
24
|
-
* const sig = serializeSignature(fingerprint);
|
|
25
|
-
* const match = matchRegistry(sig, KNOWN_PROFILES);
|
|
26
|
-
* console.log(match.name, match.similarity);
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { blake3HexStr } from '../proof/fingerprint.js';
|
|
30
|
-
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// Built-in known profiles (the baseline registry)
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// These were generated from real benchmark runs.
|
|
35
|
-
|
|
36
|
-
export const KNOWN_PROFILES = [
|
|
37
|
-
{
|
|
38
|
-
id: 'gtx1650s-i5-10400-win11',
|
|
39
|
-
name: 'GTX 1650 Super / i5-10400 / Windows 11',
|
|
40
|
-
class: 'consumer-gpu-midrange',
|
|
41
|
-
profile: 'analog-fog',
|
|
42
|
-
provider: 'physical',
|
|
43
|
-
metrics: {
|
|
44
|
-
cv: { mean: 0.1494, stddev: 0.012 },
|
|
45
|
-
hurst: { mean: 0.5505, stddev: 0.08 },
|
|
46
|
-
qe: { mean: 3.595, stddev: 0.14 },
|
|
47
|
-
lag1: { mean: 0.0698, stddev: 0.05 },
|
|
48
|
-
outlier: { mean: 0.0225, stddev: 0.006 },
|
|
49
|
-
ejr: { mean: 1.18, stddev: 0.09 }, // entropy-jitter ratio
|
|
50
|
-
},
|
|
51
|
-
contributedBy: 'Aaron Miller (sovereign-pulse author)',
|
|
52
|
-
date: '2026-03-22',
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
id: 'kvm-vps-ubuntu22-2vcpu',
|
|
56
|
-
name: 'KVM
|
|
57
|
-
class: '
|
|
58
|
-
profile: 'picket-fence',
|
|
59
|
-
provider: 'kvm-generic',
|
|
60
|
-
metrics: {
|
|
61
|
-
cv: { mean: 0.0829, stddev: 0.003 },
|
|
62
|
-
hurst: { mean: 0.0271, stddev: 0.04 },
|
|
63
|
-
qe: { mean: 1.266, stddev: 0.05 },
|
|
64
|
-
lag1: { mean: 0.666, stddev: 0.02 },
|
|
65
|
-
outlier: { mean: 0.0600, stddev: 0.000 }, // exactly 6% every run
|
|
66
|
-
ejr: { mean: 0.98, stddev: 0.03 },
|
|
67
|
-
},
|
|
68
|
-
contributedBy: 'Aaron Miller (sovereign-pulse author)',
|
|
69
|
-
date: '2026-03-22',
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
id: 'aws-t3-micro-nitro',
|
|
73
|
-
name: 'AWS EC2 t3.micro (Nitro)',
|
|
74
|
-
class: 'cloud-vm-nitro',
|
|
75
|
-
profile: 'near-physical',
|
|
76
|
-
provider: 'nitro-aws',
|
|
77
|
-
metrics: {
|
|
78
|
-
cv: { mean: 0.072, stddev: 0.015 },
|
|
79
|
-
hurst: { mean: 0.41, stddev: 0.06 },
|
|
80
|
-
qe: { mean: 2.8, stddev: 0.20 },
|
|
81
|
-
lag1: { mean: 0.18, stddev: 0.06 },
|
|
82
|
-
outlier: { mean: 0.015, stddev: 0.005 },
|
|
83
|
-
ejr: { mean: 1.01, stddev: 0.04 },
|
|
84
|
-
},
|
|
85
|
-
contributedBy: 'community',
|
|
86
|
-
date: '2026-03-22',
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
id: 'gh200-datacenter',
|
|
90
|
-
name: 'NVIDIA GH200 Grace Hopper Superchip (Datacenter VM)',
|
|
91
|
-
class: 'datacenter-gpu-highend',
|
|
92
|
-
profile: 'hypervisor-flat',
|
|
93
|
-
provider: 'gh200-datacenter',
|
|
94
|
-
metrics: {
|
|
95
|
-
cv: { mean: 0.045, stddev: 0.008 },
|
|
96
|
-
hurst: { mean: 0.038, stddev: 0.02 },
|
|
97
|
-
qe: { mean: 1.05, stddev: 0.08 },
|
|
98
|
-
lag1: { mean: 0.72, stddev: 0.03 },
|
|
99
|
-
outlier: { mean: 0.060, stddev: 0.000 },
|
|
100
|
-
ejr: { mean: 0.97, stddev: 0.02 },
|
|
101
|
-
},
|
|
102
|
-
contributedBy: 'community',
|
|
103
|
-
date: '2026-03-22',
|
|
104
|
-
notes: 'Even 480GB RAM + GH200 is trapped by the hypervisor clock. ' +
|
|
105
|
-
'The 0.72 lag-1 autocorr is the highest we have seen — the "heartbeat" ' +
|
|
106
|
-
'of a heavily-shared compute cluster.',
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
id: 'macbook-m3-pro',
|
|
110
|
-
name: 'MacBook Pro M3 / macOS Sonoma',
|
|
111
|
-
class: 'consumer-arm-laptop',
|
|
112
|
-
profile: 'analog-fog',
|
|
113
|
-
provider: 'physical',
|
|
114
|
-
metrics: {
|
|
115
|
-
cv: { mean: 0.112, stddev: 0.018 },
|
|
116
|
-
hurst: { mean: 0.53, stddev: 0.07 },
|
|
117
|
-
qe: { mean: 3.20, stddev: 0.18 },
|
|
118
|
-
lag1: { mean: 0.088, stddev: 0.04 },
|
|
119
|
-
outlier: { mean: 0.018, stddev: 0.005 },
|
|
120
|
-
ejr: { mean: 1.12, stddev: 0.07 },
|
|
121
|
-
},
|
|
122
|
-
contributedBy: 'community',
|
|
123
|
-
date: '2026-03-22',
|
|
124
|
-
notes: 'ARM efficiency cores have different thermal characteristics. ' +
|
|
125
|
-
'Lower CV than x86 at same load due to Apple Silicon power management.',
|
|
126
|
-
},
|
|
127
|
-
];
|
|
128
|
-
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
// serializeSignature
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Compress a Fingerprint's analysis into a portable, privacy-safe signature
|
|
135
|
-
* that can be submitted to the SVRN registry.
|
|
136
|
-
*
|
|
137
|
-
* @param {import('../fingerprint.js').Fingerprint} fingerprint
|
|
138
|
-
* @param {object} [meta] - optional metadata to include
|
|
139
|
-
* @param {string} [meta.hwLabel] - human label e.g. "RTX 4090 / i9-13900K"
|
|
140
|
-
* @param {string} [meta.osHint] - e.g. "Windows 11" (no version details needed)
|
|
141
|
-
* @returns {SvrnSignature}
|
|
142
|
-
*/
|
|
143
|
-
export function serializeSignature(fingerprint, meta = {}) {
|
|
144
|
-
const m = fingerprint.metrics();
|
|
145
|
-
const raw = fingerprint._raw;
|
|
146
|
-
|
|
147
|
-
// Bucket continuous metrics into coarse bins to prevent re-identification
|
|
148
|
-
const sig = {
|
|
149
|
-
version: 2,
|
|
150
|
-
id: null, // computed below
|
|
151
|
-
profile: fingerprint.profile,
|
|
152
|
-
provider: fingerprint.providerId,
|
|
153
|
-
class: _hwClass(fingerprint),
|
|
154
|
-
metrics: {
|
|
155
|
-
cv: _bucket(m.cv, [0.02, 0.06, 0.10, 0.15, 0.25, 0.40]),
|
|
156
|
-
hurst: _bucket(m.hurstExponent, [0.10, 0.25, 0.40, 0.55, 0.70, 0.85]),
|
|
157
|
-
qe: _bucket(m.quantizationEntropy, [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]),
|
|
158
|
-
lag1: _bucket(Math.abs(m.autocorrLag1 ?? 0), [0.10, 0.20, 0.35, 0.50, 0.65]),
|
|
159
|
-
outlier: _bucket(m.outlierRate, [0.005, 0.01, 0.03, 0.06, 0.10]),
|
|
160
|
-
ejr: _bucket(m.entropyJitterRatio ?? 1, [0.93, 0.97, 1.02, 1.08, 1.15, 1.30]),
|
|
161
|
-
},
|
|
162
|
-
// Renderer class (not full string — too identifying)
|
|
163
|
-
rendererClass: _rendererClass(raw.canvas?.webglRenderer, raw.canvas?.isSoftwareRenderer),
|
|
164
|
-
isSynthetic: fingerprint.isSynthetic,
|
|
165
|
-
// Optional contributor metadata
|
|
166
|
-
hwLabel: meta.hwLabel ?? null,
|
|
167
|
-
osHint: meta.osHint ?? null,
|
|
168
|
-
date: new Date().toISOString().split('T')[0],
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// Deterministic signature ID from the metric buckets
|
|
172
|
-
sig.id = 'sig_' + blake3HexStr(JSON.stringify(sig.metrics) + sig.profile + sig.rendererClass).slice(0, 12);
|
|
173
|
-
|
|
174
|
-
return sig;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* @typedef {object} SvrnSignature
|
|
179
|
-
* @property {string} version
|
|
180
|
-
* @property {string} id - deterministic signature hash
|
|
181
|
-
* @property {string} profile - timing profile type
|
|
182
|
-
* @property {string} provider - detected provider
|
|
183
|
-
* @property {string} class - hardware class
|
|
184
|
-
* @property {object} metrics - bucketed metric values
|
|
185
|
-
* @property {string} rendererClass
|
|
186
|
-
* @property {boolean} isSynthetic
|
|
187
|
-
* @property {string|null} hwLabel
|
|
188
|
-
* @property {string|null} osHint
|
|
189
|
-
* @property {string} date
|
|
190
|
-
*/
|
|
191
|
-
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// matchRegistry
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Find the closest known profile in the registry for a given signature.
|
|
198
|
-
*
|
|
199
|
-
* @param {SvrnSignature} sig
|
|
200
|
-
* @param {object[]} [registry=KNOWN_PROFILES]
|
|
201
|
-
* @returns {RegistryMatch}
|
|
202
|
-
*/
|
|
203
|
-
export function matchRegistry(sig, registry = KNOWN_PROFILES) {
|
|
204
|
-
if (!registry.length) return { matched: false, profile: null, similarity: 0 };
|
|
205
|
-
|
|
206
|
-
const scored = registry.map(known => {
|
|
207
|
-
const km = known.metrics;
|
|
208
|
-
const sm = sig.metrics;
|
|
209
|
-
|
|
210
|
-
// Z-score similarity for each metric
|
|
211
|
-
const sims = [
|
|
212
|
-
_metricSim(sm.cv?.mid, km.cv?.mean, km.cv?.stddev),
|
|
213
|
-
_metricSim(sm.hurst?.mid, km.hurst?.mean, km.hurst?.stddev),
|
|
214
|
-
_metricSim(sm.qe?.mid, km.qe?.mean, km.qe?.stddev),
|
|
215
|
-
_metricSim(sm.lag1?.mid, km.lag1?.mean, km.lag1?.stddev),
|
|
216
|
-
_metricSim(sm.outlier?.mid, km.outlier?.mean, km.outlier?.stddev),
|
|
217
|
-
].filter(v => v !== null);
|
|
218
|
-
|
|
219
|
-
const avgSim = sims.length ? sims.reduce((a, b) => a + b, 0) / sims.length : 0;
|
|
220
|
-
|
|
221
|
-
// Profile-match bonus
|
|
222
|
-
const profileMatch = sig.profile === known.profile ? 0.10 : 0;
|
|
223
|
-
const providerMatch = sig.provider === known.provider ? 0.10 : 0;
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
profile: known,
|
|
227
|
-
similarity: Math.min(1, avgSim + profileMatch + providerMatch),
|
|
228
|
-
};
|
|
229
|
-
}).sort((a, b) => b.similarity - a.similarity);
|
|
230
|
-
|
|
231
|
-
const best = scored[0];
|
|
232
|
-
|
|
233
|
-
return {
|
|
234
|
-
matched: best.similarity > 0.5,
|
|
235
|
-
profile: best.profile,
|
|
236
|
-
similarity: _round(best.similarity, 3),
|
|
237
|
-
alternatives: scored.slice(1, 3).map(s => ({
|
|
238
|
-
name: s.profile.name,
|
|
239
|
-
similarity: _round(s.similarity, 3),
|
|
240
|
-
})),
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* @typedef {object} RegistryMatch
|
|
246
|
-
* @property {boolean} matched
|
|
247
|
-
* @property {object|null} profile - matching known profile
|
|
248
|
-
* @property {number} similarity - 0.0 – 1.0
|
|
249
|
-
* @property {object[]} alternatives
|
|
250
|
-
*/
|
|
251
|
-
|
|
252
|
-
// ---------------------------------------------------------------------------
|
|
253
|
-
// compareSignatures
|
|
254
|
-
// ---------------------------------------------------------------------------
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Check if two signatures are from the same device class.
|
|
258
|
-
* Useful for detecting when a bot submits two proofs that should match
|
|
259
|
-
* but actually come from different VMs (load-balanced bot farm).
|
|
260
|
-
*
|
|
261
|
-
* @param {SvrnSignature} a
|
|
262
|
-
* @param {SvrnSignature} b
|
|
263
|
-
* @returns {{ sameClass: boolean, similarity: number }}
|
|
264
|
-
*/
|
|
265
|
-
export function compareSignatures(a, b) {
|
|
266
|
-
const keys = ['cv', 'hurst', 'qe', 'lag1', 'outlier'];
|
|
267
|
-
const sims = keys.map(k => {
|
|
268
|
-
const am = a.metrics[k]?.mid;
|
|
269
|
-
const bm = b.metrics[k]?.mid;
|
|
270
|
-
if (am == null || bm == null) return null;
|
|
271
|
-
const diff = Math.abs(am - bm);
|
|
272
|
-
const scale = Math.max(Math.abs(am), Math.abs(bm), 0.001);
|
|
273
|
-
return Math.max(0, 1 - diff / scale);
|
|
274
|
-
}).filter(v => v !== null);
|
|
275
|
-
|
|
276
|
-
const similarity = sims.length ? sims.reduce((a, b) => a + b, 0) / sims.length : 0;
|
|
277
|
-
|
|
278
|
-
return {
|
|
279
|
-
sameClass: similarity > 0.75 && a.profile === b.profile,
|
|
280
|
-
similarity: _round(similarity, 3),
|
|
281
|
-
profileMatch: a.profile === b.profile,
|
|
282
|
-
providerMatch: a.provider === b.provider,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ---------------------------------------------------------------------------
|
|
287
|
-
// Internal helpers
|
|
288
|
-
// ---------------------------------------------------------------------------
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Bucket a continuous value into a range with labeled boundaries.
|
|
292
|
-
* Returns { lo, hi, mid, label } — enough to compare without exact values.
|
|
293
|
-
*/
|
|
294
|
-
function _bucket(value, boundaries) {
|
|
295
|
-
if (value == null || !isFinite(value)) return null;
|
|
296
|
-
for (let i = 0; i < boundaries.length; i++) {
|
|
297
|
-
if (value < boundaries[i]) {
|
|
298
|
-
const lo = i === 0 ? 0 : boundaries[i - 1];
|
|
299
|
-
const hi = boundaries[i];
|
|
300
|
-
return { lo, hi, mid: (lo + hi) / 2, label: `${lo}–${hi}` };
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
const lo = boundaries[boundaries.length - 1];
|
|
304
|
-
return { lo, hi: lo * 2, mid: lo * 1.5, label: `>${lo}` };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function _metricSim(val, mean, stddev) {
|
|
308
|
-
if (val == null || mean == null || !stddev) return null;
|
|
309
|
-
const z = Math.abs(val - mean) / stddev;
|
|
310
|
-
// Gaussian similarity: 1 at z=0, near 0 at z≥3
|
|
311
|
-
return Math.exp(-0.5 * z * z);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function _hwClass(fingerprint) {
|
|
315
|
-
if (!fingerprint.isSynthetic) {
|
|
316
|
-
const r = fingerprint._raw.canvas?.webglRenderer?.toLowerCase() ?? '';
|
|
317
|
-
if (r.includes('apple')) return 'consumer-arm-laptop';
|
|
318
|
-
if (r.includes('radeon')) return 'consumer-gpu-amd';
|
|
319
|
-
if (r.includes('geforce') || r.includes('nvidia')) return 'consumer-gpu-nvidia';
|
|
320
|
-
if (r.includes('intel')) return 'consumer-igpu-intel';
|
|
321
|
-
return 'consumer-unknown';
|
|
322
|
-
}
|
|
323
|
-
const p = fingerprint.providerId;
|
|
324
|
-
if (p.includes('nitro')) return 'cloud-vm-nitro';
|
|
325
|
-
if (p.includes('gh200')) return 'datacenter-gpu-highend';
|
|
326
|
-
if (p.includes('digitalocean')) return 'cloud-vm-budget';
|
|
327
|
-
if (p.includes('aws')) return 'cloud-vm-aws';
|
|
328
|
-
if (p.includes('gcp')) return 'cloud-vm-gcp';
|
|
329
|
-
if (p.includes('vmware')) return 'cloud-vm-vmware';
|
|
330
|
-
return 'cloud-vm-generic';
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function _rendererClass(renderer = '', isSoftware = false) {
|
|
334
|
-
if (isSoftware) return 'software';
|
|
335
|
-
const r = renderer.toLowerCase();
|
|
336
|
-
if (r.includes('apple')) return 'apple-metal';
|
|
337
|
-
if (r.includes('geforce')) return 'nvidia-consumer';
|
|
338
|
-
if (r.includes('radeon')) return 'amd-consumer';
|
|
339
|
-
if (r.includes('intel')) return 'intel-igpu';
|
|
340
|
-
if (r.includes('quadro') || r.includes('tesla') || r.includes('a100') ||
|
|
341
|
-
r.includes('h100') || r.includes('gh200')) return 'nvidia-datacenter';
|
|
342
|
-
return 'unknown';
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function _round(v, d) {
|
|
346
|
-
if (v == null) return null;
|
|
347
|
-
const f = 10 ** d;
|
|
348
|
-
return Math.round(v * f) / f;
|
|
349
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — SVRN Registry Signature Serializer
|
|
3
|
+
*
|
|
4
|
+
* The registry is a crowdsourced database of device "Silicon Signatures" —
|
|
5
|
+
* compact, privacy-safe profiles that characterise a device class rather than
|
|
6
|
+
* an individual device. Developers submit signatures from their test hardware
|
|
7
|
+
* to help calibrate the classifier for hardware we haven't seen yet.
|
|
8
|
+
*
|
|
9
|
+
* A signature captures:
|
|
10
|
+
* - The statistical "shape" of the timing distribution (not raw timings)
|
|
11
|
+
* - The provider/hypervisor classification
|
|
12
|
+
* - The timing profile type (analog-fog vs picket-fence)
|
|
13
|
+
* - Hardware generation hints (GPU vendor, renderer class)
|
|
14
|
+
*
|
|
15
|
+
* What a signature does NOT capture:
|
|
16
|
+
* - Any individual timing values
|
|
17
|
+
* - Canvas pixel data
|
|
18
|
+
* - Mouse coordinates or keystrokes
|
|
19
|
+
* - Any user-identifiable information
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* import { serializeSignature, matchRegistry, KNOWN_PROFILES } from '@svrnsec/pulse/registry';
|
|
23
|
+
*
|
|
24
|
+
* const sig = serializeSignature(fingerprint);
|
|
25
|
+
* const match = matchRegistry(sig, KNOWN_PROFILES);
|
|
26
|
+
* console.log(match.name, match.similarity);
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { blake3HexStr } from '../proof/fingerprint.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Built-in known profiles (the baseline registry)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// These were generated from real benchmark runs.
|
|
35
|
+
|
|
36
|
+
export const KNOWN_PROFILES = [
|
|
37
|
+
{
|
|
38
|
+
id: 'gtx1650s-i5-10400-win11',
|
|
39
|
+
name: 'GTX 1650 Super / i5-10400 / Windows 11',
|
|
40
|
+
class: 'consumer-gpu-midrange',
|
|
41
|
+
profile: 'analog-fog',
|
|
42
|
+
provider: 'physical',
|
|
43
|
+
metrics: {
|
|
44
|
+
cv: { mean: 0.1494, stddev: 0.012 },
|
|
45
|
+
hurst: { mean: 0.5505, stddev: 0.08 },
|
|
46
|
+
qe: { mean: 3.595, stddev: 0.14 },
|
|
47
|
+
lag1: { mean: 0.0698, stddev: 0.05 },
|
|
48
|
+
outlier: { mean: 0.0225, stddev: 0.006 },
|
|
49
|
+
ejr: { mean: 1.18, stddev: 0.09 }, // entropy-jitter ratio
|
|
50
|
+
},
|
|
51
|
+
contributedBy: 'Aaron Miller (sovereign-pulse author)',
|
|
52
|
+
date: '2026-03-22',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'kvm-vps-ubuntu22-2vcpu',
|
|
56
|
+
name: 'KVM VM / Ubuntu 22.04 / 12 vCPU / 480GB RAM / NVIDIA GH200 Grace Hopper',
|
|
57
|
+
class: 'datacenter-gpu-highend',
|
|
58
|
+
profile: 'picket-fence',
|
|
59
|
+
provider: 'kvm-generic',
|
|
60
|
+
metrics: {
|
|
61
|
+
cv: { mean: 0.0829, stddev: 0.003 },
|
|
62
|
+
hurst: { mean: 0.0271, stddev: 0.04 },
|
|
63
|
+
qe: { mean: 1.266, stddev: 0.05 },
|
|
64
|
+
lag1: { mean: 0.666, stddev: 0.02 },
|
|
65
|
+
outlier: { mean: 0.0600, stddev: 0.000 }, // exactly 6% every run
|
|
66
|
+
ejr: { mean: 0.98, stddev: 0.03 },
|
|
67
|
+
},
|
|
68
|
+
contributedBy: 'Aaron Miller (sovereign-pulse author)',
|
|
69
|
+
date: '2026-03-22',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'aws-t3-micro-nitro',
|
|
73
|
+
name: 'AWS EC2 t3.micro (Nitro)',
|
|
74
|
+
class: 'cloud-vm-nitro',
|
|
75
|
+
profile: 'near-physical',
|
|
76
|
+
provider: 'nitro-aws',
|
|
77
|
+
metrics: {
|
|
78
|
+
cv: { mean: 0.072, stddev: 0.015 },
|
|
79
|
+
hurst: { mean: 0.41, stddev: 0.06 },
|
|
80
|
+
qe: { mean: 2.8, stddev: 0.20 },
|
|
81
|
+
lag1: { mean: 0.18, stddev: 0.06 },
|
|
82
|
+
outlier: { mean: 0.015, stddev: 0.005 },
|
|
83
|
+
ejr: { mean: 1.01, stddev: 0.04 },
|
|
84
|
+
},
|
|
85
|
+
contributedBy: 'community',
|
|
86
|
+
date: '2026-03-22',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'gh200-datacenter',
|
|
90
|
+
name: 'NVIDIA GH200 Grace Hopper Superchip (Datacenter VM)',
|
|
91
|
+
class: 'datacenter-gpu-highend',
|
|
92
|
+
profile: 'hypervisor-flat',
|
|
93
|
+
provider: 'gh200-datacenter',
|
|
94
|
+
metrics: {
|
|
95
|
+
cv: { mean: 0.045, stddev: 0.008 },
|
|
96
|
+
hurst: { mean: 0.038, stddev: 0.02 },
|
|
97
|
+
qe: { mean: 1.05, stddev: 0.08 },
|
|
98
|
+
lag1: { mean: 0.72, stddev: 0.03 },
|
|
99
|
+
outlier: { mean: 0.060, stddev: 0.000 },
|
|
100
|
+
ejr: { mean: 0.97, stddev: 0.02 },
|
|
101
|
+
},
|
|
102
|
+
contributedBy: 'community',
|
|
103
|
+
date: '2026-03-22',
|
|
104
|
+
notes: 'Even 480GB RAM + GH200 is trapped by the hypervisor clock. ' +
|
|
105
|
+
'The 0.72 lag-1 autocorr is the highest we have seen — the "heartbeat" ' +
|
|
106
|
+
'of a heavily-shared compute cluster.',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'macbook-m3-pro',
|
|
110
|
+
name: 'MacBook Pro M3 / macOS Sonoma',
|
|
111
|
+
class: 'consumer-arm-laptop',
|
|
112
|
+
profile: 'analog-fog',
|
|
113
|
+
provider: 'physical',
|
|
114
|
+
metrics: {
|
|
115
|
+
cv: { mean: 0.112, stddev: 0.018 },
|
|
116
|
+
hurst: { mean: 0.53, stddev: 0.07 },
|
|
117
|
+
qe: { mean: 3.20, stddev: 0.18 },
|
|
118
|
+
lag1: { mean: 0.088, stddev: 0.04 },
|
|
119
|
+
outlier: { mean: 0.018, stddev: 0.005 },
|
|
120
|
+
ejr: { mean: 1.12, stddev: 0.07 },
|
|
121
|
+
},
|
|
122
|
+
contributedBy: 'community',
|
|
123
|
+
date: '2026-03-22',
|
|
124
|
+
notes: 'ARM efficiency cores have different thermal characteristics. ' +
|
|
125
|
+
'Lower CV than x86 at same load due to Apple Silicon power management.',
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// serializeSignature
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Compress a Fingerprint's analysis into a portable, privacy-safe signature
|
|
135
|
+
* that can be submitted to the SVRN registry.
|
|
136
|
+
*
|
|
137
|
+
* @param {import('../fingerprint.js').Fingerprint} fingerprint
|
|
138
|
+
* @param {object} [meta] - optional metadata to include
|
|
139
|
+
* @param {string} [meta.hwLabel] - human label e.g. "RTX 4090 / i9-13900K"
|
|
140
|
+
* @param {string} [meta.osHint] - e.g. "Windows 11" (no version details needed)
|
|
141
|
+
* @returns {SvrnSignature}
|
|
142
|
+
*/
|
|
143
|
+
export function serializeSignature(fingerprint, meta = {}) {
|
|
144
|
+
const m = fingerprint.metrics();
|
|
145
|
+
const raw = fingerprint._raw;
|
|
146
|
+
|
|
147
|
+
// Bucket continuous metrics into coarse bins to prevent re-identification
|
|
148
|
+
const sig = {
|
|
149
|
+
version: 2,
|
|
150
|
+
id: null, // computed below
|
|
151
|
+
profile: fingerprint.profile,
|
|
152
|
+
provider: fingerprint.providerId,
|
|
153
|
+
class: _hwClass(fingerprint),
|
|
154
|
+
metrics: {
|
|
155
|
+
cv: _bucket(m.cv, [0.02, 0.06, 0.10, 0.15, 0.25, 0.40]),
|
|
156
|
+
hurst: _bucket(m.hurstExponent, [0.10, 0.25, 0.40, 0.55, 0.70, 0.85]),
|
|
157
|
+
qe: _bucket(m.quantizationEntropy, [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]),
|
|
158
|
+
lag1: _bucket(Math.abs(m.autocorrLag1 ?? 0), [0.10, 0.20, 0.35, 0.50, 0.65]),
|
|
159
|
+
outlier: _bucket(m.outlierRate, [0.005, 0.01, 0.03, 0.06, 0.10]),
|
|
160
|
+
ejr: _bucket(m.entropyJitterRatio ?? 1, [0.93, 0.97, 1.02, 1.08, 1.15, 1.30]),
|
|
161
|
+
},
|
|
162
|
+
// Renderer class (not full string — too identifying)
|
|
163
|
+
rendererClass: _rendererClass(raw.canvas?.webglRenderer, raw.canvas?.isSoftwareRenderer),
|
|
164
|
+
isSynthetic: fingerprint.isSynthetic,
|
|
165
|
+
// Optional contributor metadata
|
|
166
|
+
hwLabel: meta.hwLabel ?? null,
|
|
167
|
+
osHint: meta.osHint ?? null,
|
|
168
|
+
date: new Date().toISOString().split('T')[0],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Deterministic signature ID from the metric buckets
|
|
172
|
+
sig.id = 'sig_' + blake3HexStr(JSON.stringify(sig.metrics) + sig.profile + sig.rendererClass).slice(0, 12);
|
|
173
|
+
|
|
174
|
+
return sig;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @typedef {object} SvrnSignature
|
|
179
|
+
* @property {string} version
|
|
180
|
+
* @property {string} id - deterministic signature hash
|
|
181
|
+
* @property {string} profile - timing profile type
|
|
182
|
+
* @property {string} provider - detected provider
|
|
183
|
+
* @property {string} class - hardware class
|
|
184
|
+
* @property {object} metrics - bucketed metric values
|
|
185
|
+
* @property {string} rendererClass
|
|
186
|
+
* @property {boolean} isSynthetic
|
|
187
|
+
* @property {string|null} hwLabel
|
|
188
|
+
* @property {string|null} osHint
|
|
189
|
+
* @property {string} date
|
|
190
|
+
*/
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// matchRegistry
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Find the closest known profile in the registry for a given signature.
|
|
198
|
+
*
|
|
199
|
+
* @param {SvrnSignature} sig
|
|
200
|
+
* @param {object[]} [registry=KNOWN_PROFILES]
|
|
201
|
+
* @returns {RegistryMatch}
|
|
202
|
+
*/
|
|
203
|
+
export function matchRegistry(sig, registry = KNOWN_PROFILES) {
|
|
204
|
+
if (!registry.length) return { matched: false, profile: null, similarity: 0 };
|
|
205
|
+
|
|
206
|
+
const scored = registry.map(known => {
|
|
207
|
+
const km = known.metrics;
|
|
208
|
+
const sm = sig.metrics;
|
|
209
|
+
|
|
210
|
+
// Z-score similarity for each metric
|
|
211
|
+
const sims = [
|
|
212
|
+
_metricSim(sm.cv?.mid, km.cv?.mean, km.cv?.stddev),
|
|
213
|
+
_metricSim(sm.hurst?.mid, km.hurst?.mean, km.hurst?.stddev),
|
|
214
|
+
_metricSim(sm.qe?.mid, km.qe?.mean, km.qe?.stddev),
|
|
215
|
+
_metricSim(sm.lag1?.mid, km.lag1?.mean, km.lag1?.stddev),
|
|
216
|
+
_metricSim(sm.outlier?.mid, km.outlier?.mean, km.outlier?.stddev),
|
|
217
|
+
].filter(v => v !== null);
|
|
218
|
+
|
|
219
|
+
const avgSim = sims.length ? sims.reduce((a, b) => a + b, 0) / sims.length : 0;
|
|
220
|
+
|
|
221
|
+
// Profile-match bonus
|
|
222
|
+
const profileMatch = sig.profile === known.profile ? 0.10 : 0;
|
|
223
|
+
const providerMatch = sig.provider === known.provider ? 0.10 : 0;
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
profile: known,
|
|
227
|
+
similarity: Math.min(1, avgSim + profileMatch + providerMatch),
|
|
228
|
+
};
|
|
229
|
+
}).sort((a, b) => b.similarity - a.similarity);
|
|
230
|
+
|
|
231
|
+
const best = scored[0];
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
matched: best.similarity > 0.5,
|
|
235
|
+
profile: best.profile,
|
|
236
|
+
similarity: _round(best.similarity, 3),
|
|
237
|
+
alternatives: scored.slice(1, 3).map(s => ({
|
|
238
|
+
name: s.profile.name,
|
|
239
|
+
similarity: _round(s.similarity, 3),
|
|
240
|
+
})),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @typedef {object} RegistryMatch
|
|
246
|
+
* @property {boolean} matched
|
|
247
|
+
* @property {object|null} profile - matching known profile
|
|
248
|
+
* @property {number} similarity - 0.0 – 1.0
|
|
249
|
+
* @property {object[]} alternatives
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// compareSignatures
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Check if two signatures are from the same device class.
|
|
258
|
+
* Useful for detecting when a bot submits two proofs that should match
|
|
259
|
+
* but actually come from different VMs (load-balanced bot farm).
|
|
260
|
+
*
|
|
261
|
+
* @param {SvrnSignature} a
|
|
262
|
+
* @param {SvrnSignature} b
|
|
263
|
+
* @returns {{ sameClass: boolean, similarity: number }}
|
|
264
|
+
*/
|
|
265
|
+
export function compareSignatures(a, b) {
|
|
266
|
+
const keys = ['cv', 'hurst', 'qe', 'lag1', 'outlier'];
|
|
267
|
+
const sims = keys.map(k => {
|
|
268
|
+
const am = a.metrics[k]?.mid;
|
|
269
|
+
const bm = b.metrics[k]?.mid;
|
|
270
|
+
if (am == null || bm == null) return null;
|
|
271
|
+
const diff = Math.abs(am - bm);
|
|
272
|
+
const scale = Math.max(Math.abs(am), Math.abs(bm), 0.001);
|
|
273
|
+
return Math.max(0, 1 - diff / scale);
|
|
274
|
+
}).filter(v => v !== null);
|
|
275
|
+
|
|
276
|
+
const similarity = sims.length ? sims.reduce((a, b) => a + b, 0) / sims.length : 0;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
sameClass: similarity > 0.75 && a.profile === b.profile,
|
|
280
|
+
similarity: _round(similarity, 3),
|
|
281
|
+
profileMatch: a.profile === b.profile,
|
|
282
|
+
providerMatch: a.provider === b.provider,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Internal helpers
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Bucket a continuous value into a range with labeled boundaries.
|
|
292
|
+
* Returns { lo, hi, mid, label } — enough to compare without exact values.
|
|
293
|
+
*/
|
|
294
|
+
function _bucket(value, boundaries) {
|
|
295
|
+
if (value == null || !isFinite(value)) return null;
|
|
296
|
+
for (let i = 0; i < boundaries.length; i++) {
|
|
297
|
+
if (value < boundaries[i]) {
|
|
298
|
+
const lo = i === 0 ? 0 : boundaries[i - 1];
|
|
299
|
+
const hi = boundaries[i];
|
|
300
|
+
return { lo, hi, mid: (lo + hi) / 2, label: `${lo}–${hi}` };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const lo = boundaries[boundaries.length - 1];
|
|
304
|
+
return { lo, hi: lo * 2, mid: lo * 1.5, label: `>${lo}` };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function _metricSim(val, mean, stddev) {
|
|
308
|
+
if (val == null || mean == null || !stddev) return null;
|
|
309
|
+
const z = Math.abs(val - mean) / stddev;
|
|
310
|
+
// Gaussian similarity: 1 at z=0, near 0 at z≥3
|
|
311
|
+
return Math.exp(-0.5 * z * z);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function _hwClass(fingerprint) {
|
|
315
|
+
if (!fingerprint.isSynthetic) {
|
|
316
|
+
const r = fingerprint._raw.canvas?.webglRenderer?.toLowerCase() ?? '';
|
|
317
|
+
if (r.includes('apple')) return 'consumer-arm-laptop';
|
|
318
|
+
if (r.includes('radeon')) return 'consumer-gpu-amd';
|
|
319
|
+
if (r.includes('geforce') || r.includes('nvidia')) return 'consumer-gpu-nvidia';
|
|
320
|
+
if (r.includes('intel')) return 'consumer-igpu-intel';
|
|
321
|
+
return 'consumer-unknown';
|
|
322
|
+
}
|
|
323
|
+
const p = fingerprint.providerId;
|
|
324
|
+
if (p.includes('nitro')) return 'cloud-vm-nitro';
|
|
325
|
+
if (p.includes('gh200')) return 'datacenter-gpu-highend';
|
|
326
|
+
if (p.includes('digitalocean')) return 'cloud-vm-budget';
|
|
327
|
+
if (p.includes('aws')) return 'cloud-vm-aws';
|
|
328
|
+
if (p.includes('gcp')) return 'cloud-vm-gcp';
|
|
329
|
+
if (p.includes('vmware')) return 'cloud-vm-vmware';
|
|
330
|
+
return 'cloud-vm-generic';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function _rendererClass(renderer = '', isSoftware = false) {
|
|
334
|
+
if (isSoftware) return 'software';
|
|
335
|
+
const r = renderer.toLowerCase();
|
|
336
|
+
if (r.includes('apple')) return 'apple-metal';
|
|
337
|
+
if (r.includes('geforce')) return 'nvidia-consumer';
|
|
338
|
+
if (r.includes('radeon')) return 'amd-consumer';
|
|
339
|
+
if (r.includes('intel')) return 'intel-igpu';
|
|
340
|
+
if (r.includes('quadro') || r.includes('tesla') || r.includes('a100') ||
|
|
341
|
+
r.includes('h100') || r.includes('gh200')) return 'nvidia-datacenter';
|
|
342
|
+
return 'unknown';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _round(v, d) {
|
|
346
|
+
if (v == null) return null;
|
|
347
|
+
const f = 10 ** d;
|
|
348
|
+
return Math.round(v * f) / f;
|
|
349
|
+
}
|