@anonympins/fingerprint 0.7.5 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/public/anonympins-bot-mitigation-pow.zip +0 -0
  5. package/src/js/fingerprint.client.js +1784 -1772
  6. package/src/js/fingerprint.client.obfuscated.js +1 -1
  7. package/src/js/fingerprint.js +198 -28
  8. package/src/js/fingerprint.utils.js +252 -2
  9. package/src/js/pow.worker.js +2 -2
  10. package/src/js/tests/cross_parity.test.js +3 -3
  11. package/src/js/tests/dns-circuit-breaker.test.js +6 -6
  12. package/src/js/tests/fingerprint.builder.test.js +76 -78
  13. package/src/js/tests/fingerprint.client.test.js +16 -17
  14. package/src/js/tests/fingerprint.test.js +19 -12
  15. package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
  16. package/src/js/tests/library.test.js +94 -95
  17. package/src/js/tests/obfuscation.test.js +8 -8
  18. package/src/js/tests/quicFingerprint.test.js +47 -0
  19. package/src/js/tests/tcpFingerprint.test.js +4 -4
  20. package/src/js/upow-model-task.js +155 -112
  21. package/src/php/Challenge/ChallengeUtils.php +382 -86
  22. package/src/php/Config/SecurityProfiles.php +75 -62
  23. package/src/php/DirectFingerprint.php +55 -8
  24. package/src/php/FingerprintBuilder.php +13 -13
  25. package/src/php/FingerprintClient.php +6 -6
  26. package/src/php/FingerprintEngine.php +2120 -1837
  27. package/src/php/Ja3AnomalyDetector.php +22 -22
  28. package/src/php/Optimization/FunctionRegistry.php +4 -4
  29. package/src/php/Optimization/Optimization.php +6 -6
  30. package/src/php/RequestContext.php +11 -11
  31. package/src/php/Store/InMemoryStore.php +96 -66
  32. package/src/php/Store/MongoDbStore.php +2 -2
  33. package/src/php/Store/RedisStore.php +1 -1
  34. package/src/php/Store/StoreManager.php +48 -35
  35. package/src/php/Tests/ChallengeUtilsTest.php +31 -0
  36. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  37. package/src/php/Tests/FingerprintClientTest.php +70 -70
  38. package/src/php/Tests/PowTest.php +37 -39
  39. package/src/php/Tests/QuicFingerprintTest.php +115 -53
  40. package/src/php/Tests/ThreatIntelTest.php +2 -2
  41. package/src/php/Utils/BigInt.php +202 -202
  42. package/src/php/Utils/BlockList.php +13 -13
  43. package/src/php/Utils/Logger.php +19 -1
  44. package/src/php/Utils/MaliciousPatterns.php +6 -6
  45. package/src/php/Utils/MetricsManager.php +209 -209
  46. package/src/php/Utils/RequestUtils.php +327 -14
  47. package/src/php/Utils/TLSClientHelloParser.php +385 -369
  48. package/src/php/WordPress/WpDbStore.php +7 -7
  49. package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1121 -0
  50. package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
  51. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
  52. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -0
  53. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  54. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +297 -261
  55. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  56. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
  57. package/src/php/WordPress/package.php +80 -101
  58. package/src/php/WordPress/readme.txt +21 -5
  59. package/src/php/bin/auto-tune.php +1 -1
  60. package/public/fingerprint-anti-bot.zip +0 -0
@@ -1,5 +1,18 @@
1
1
  import {cyrb53} from "./fingerprint.builder.js";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
2
5
 
6
+ /**
7
+ * Verifies a Zero-Knowledge Proof (ZKP) of the Schnorr type.
8
+ * The verification checks that g^s ≡ t * y^c (mod p),
9
+ * where c is a challenge computed by SHA-256 hashing of (g, y, t).
10
+ *
11
+ * @param {string} yStr - The public key y, hex-encoded (without the 0x prefix).
12
+ * @param {string} tStr - The commitment t, hex-encoded (without the 0x prefix).
13
+ * @param {string} sStr - The response s, hex-encoded (without the 0x prefix).
14
+ * @returns {boolean} True if the proof is valid, false otherwise (or on error).
15
+ */
3
16
  export function verifyZkpProof(yStr, tStr, sStr) {
4
17
  try {
5
18
  const y = BigInt('0x' + yStr);
@@ -19,6 +32,14 @@ export function verifyZkpProof(yStr, tStr, sStr) {
19
32
  }
20
33
  }
21
34
 
35
+ /**
36
+ * Serializes a value to JSON while escaping dangerous characters
37
+ * for safe inclusion in HTML/JS (XSS prevention).
38
+ * Escapes <, >, U+2028, and U+2029.
39
+ *
40
+ * @param {*} val - The value to serialize.
41
+ * @returns {string} The escaped JSON string.
42
+ */
22
43
  export function safeJsonStringify(val) {
23
44
  return JSON.stringify(val)
24
45
  .replace(/</g, '\\u003c')
@@ -27,6 +48,16 @@ export function safeJsonStringify(val) {
27
48
  .replace(/\u2029/g, '\\u2029');
28
49
  }
29
50
 
51
+ /**
52
+ * Sanitizes and validates a redirect path to prevent open redirects.
53
+ * - Strips out disallowed characters.
54
+ * - Prevents protocol-relative redirects (//evil.com).
55
+ * - Prevents absolute redirects (http://evil.com).
56
+ * - Ensures the path starts with a single '/'.
57
+ *
58
+ * @param {string} p - The raw redirect path.
59
+ * @returns {string} The sanitized path (or '/' by default).
60
+ */
30
61
  export function sanitizeRedirectPath(p) {
31
62
  if (typeof p !== 'string') return '/';
32
63
  let sanitized = p.replace(/[^a-zA-Z0-9\/.\-_~%?&=:@+,;]/g, '');
@@ -49,6 +80,16 @@ export function sanitizeRedirectPath(p) {
49
80
  }
50
81
  return sanitized.replace(/^\/+/g, '/');
51
82
  }
83
+
84
+ /**
85
+ * Decodes a polymorphic fingerprint by restoring the original keys
86
+ * from a mapping of randomized keys.
87
+ * Expected format: "randKey: value|randKey: value|...".
88
+ *
89
+ * @param {string} fpString - The encoded fingerprint.
90
+ * @param {object} mapping - The mapping object containing `keys` (orig -> rand).
91
+ * @returns {string} The decoded fingerprint with original keys.
92
+ */
52
93
  export function decodePolymorphicFingerprint(fpString, mapping) {
53
94
  if (!fpString || !mapping || !mapping.keys) return fpString;
54
95
  const reverseKeys = {};
@@ -67,6 +108,130 @@ export function decodePolymorphicFingerprint(fpString, mapping) {
67
108
  return mappedParts.join('|');
68
109
  }
69
110
 
111
+ /**
112
+ * Extracts and parses a Private Access Token (PAT / Privacy Pass / RFC 9578).
113
+ * Supports 'Authorization: PrivateToken token="..."' and 'Sec-Private-State-Token'.
114
+ *
115
+ * @param {object} headers - HTTP request headers object.
116
+ * @returns {Array<Uint8Array>} Array of raw binary tokens.
117
+ */
118
+ export function extractPrivateAccessTokens(headers) {
119
+ if (!headers) return [];
120
+ const tokens = [];
121
+
122
+ const authHeader = headers['authorization'] || headers['Authorization'];
123
+ if (typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('privatetoken ')) {
124
+ const match = authHeader.match(/token=(?:"([^"]+)"|([a-zA-Z0-9_\-+/=]+))/i);
125
+ if (match) {
126
+ const rawB64 = match[1] || match[2];
127
+ try {
128
+ tokens.push(Buffer.from(rawB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64'));
129
+ } catch (e) {}
130
+ }
131
+ }
132
+
133
+ const pstHeader = headers['sec-private-state-token'] || headers['Sec-Private-State-Token'];
134
+ if (typeof pstHeader === 'string') {
135
+ const parts = pstHeader.split(',');
136
+ for (const part of parts) {
137
+ const trimmed = part.trim();
138
+ if (trimmed) {
139
+ try {
140
+ tokens.push(Buffer.from(trimmed.replace(/-/g, '+').replace(/_/g, '/'), 'base64'));
141
+ } catch (e) {}
142
+ }
143
+ }
144
+ }
145
+
146
+ return tokens;
147
+ }
148
+
149
+ /**
150
+ * Decodes an RFC 9578 Token structure:
151
+ * uint16_t token_type (2 bytes)
152
+ * uint8_t nonce[32] (32 bytes)
153
+ * uint8_t challenge_digest[32] (32 bytes)
154
+ * uint8_t token_key_id[32] (32 bytes)
155
+ * uint8_t authenticator[Nk] (>= 32 bytes)
156
+ *
157
+ * @param {Uint8Array|Buffer} buffer
158
+ * @returns {object|null}
159
+ */
160
+ export function parsePrivateAccessToken(buffer) {
161
+ if (!buffer || buffer.length < 98) return null; // 2 + 32 + 32 + 32 = 98 min
162
+
163
+ const view = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
164
+ const tokenType = view.readUInt16BE(0);
165
+ const nonce = view.subarray(2, 34);
166
+ const challengeDigest = view.subarray(34, 66);
167
+ const tokenKeyId = view.subarray(66, 98);
168
+ const authenticator = view.subarray(98);
169
+
170
+ if (authenticator.length === 0) return null;
171
+
172
+ return {
173
+ tokenType,
174
+ nonce: nonce.toString('hex'),
175
+ challengeDigest: challengeDigest.toString('hex'),
176
+ tokenKeyId: tokenKeyId.toString('hex'),
177
+ authenticator,
178
+ signedData: view.subarray(0, 98)
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Validates a decoded Private Access Token signature against configured public keys.
184
+ *
185
+ * @param {object} parsedToken - Output from parsePrivateAccessToken.
186
+ * @param {object} patConfig - Configuration with trustedKeys mapping.
187
+ * @returns {boolean} True if signature is cryptographically valid.
188
+ */
189
+ export function verifyPatSignature(parsedToken, patConfig = {}) {
190
+ if (!parsedToken) return false;
191
+ const trustedKeys = patConfig.trustedKeys || {};
192
+ const keyId = parsedToken.tokenKeyId;
193
+ const publicKey = trustedKeys[keyId] || trustedKeys[keyId.toLowerCase()] || patConfig.defaultPublicKey;
194
+
195
+ if (!publicKey) {
196
+ return false;
197
+ }
198
+
199
+ const { tokenType, signedData, authenticator } = parsedToken;
200
+
201
+ try {
202
+ // Type 0x0001 (Blind RSA 2048) / 0x0002 (Rate-Limited Blind RSA)
203
+ if (tokenType === 0x0001 || tokenType === 0x0002 || tokenType === 1 || tokenType === 2) {
204
+ // Try RSA-PSS with SHA-384 (RFC 9577) first, fallback to SHA-256 and PKCS#1 v1.5
205
+ const algorithms = [
206
+ { hash: 'sha384', padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 48 },
207
+ { hash: 'sha256', padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 },
208
+ { hash: 'sha384', padding: crypto.constants.RSA_PKCS1_PADDING },
209
+ { hash: 'sha256', padding: crypto.constants.RSA_PKCS1_PADDING }
210
+ ];
211
+
212
+ for (const alg of algorithms) {
213
+ try {
214
+ const verifier = crypto.createVerify(alg.hash);
215
+ verifier.update(signedData);
216
+ const isValid = verifier.verify({
217
+ key: publicKey,
218
+ padding: alg.padding,
219
+ saltLength: alg.saltLength
220
+ }, authenticator);
221
+ if (isValid) return true;
222
+ } catch (verifyErr) {}
223
+ }
224
+ } else if (tokenType === 0x0003 || tokenType === 3) {
225
+ // VOPRF / Ed25519 authentication verification
226
+ try {
227
+ return crypto.verify(null, signedData, publicKey, authenticator);
228
+ } catch (voprfErr) {}
229
+ }
230
+ } catch (e) {
231
+ return false;
232
+ }
233
+ return false;
234
+ }
70
235
 
71
236
  /**
72
237
  * @private
@@ -127,6 +292,15 @@ export function parseJa3(ja3String) {
127
292
  };
128
293
  }
129
294
 
295
+ /**
296
+ * Computes modular exponentiation (base^exponent mod modulus) efficiently
297
+ * using the binary exponentiation method (square-and-multiply).
298
+ *
299
+ * @param {bigint} base - The base.
300
+ * @param {bigint} exponent - The exponent.
301
+ * @param {bigint} modulus - The modulus.
302
+ * @returns {bigint} The result of base^exponent mod modulus.
303
+ */
130
304
  export function modPow(base, exponent, modulus) {
131
305
  if (modulus === 1n) return 0n;
132
306
  let result = 1n;
@@ -142,6 +316,14 @@ export function modPow(base, exponent, modulus) {
142
316
  }
143
317
 
144
318
 
319
+ /**
320
+ * Computes a simple hash of the IP network by applying a mask (CIDR prefix).
321
+ * For example, with a prefix of 24, only the first 3 octets are kept.
322
+ *
323
+ * @param {string} ip - The IP address in IPv4 format (e.g. "192.168.1.42").
324
+ * @param {number} [prefix=24] - The network prefix length (in bits).
325
+ * @returns {string|null} A hexadecimal hash of the network, or null if the IP is invalid.
326
+ */
145
327
  export function hashNetwork(ip, prefix = 24) {
146
328
  // Network hash (/24 or /16 mask)
147
329
  const parts = ip.split('.');
@@ -157,6 +339,14 @@ export function hashNetwork(ip, prefix = 24) {
157
339
  }
158
340
  return hash.toString(16);
159
341
  }
342
+
343
+ /**
344
+ * Checks whether an IP address is a loopback address (localhost).
345
+ * Handles IPv4, IPv6, and the IPv4-mapped prefix (::ffff:).
346
+ *
347
+ * @param {string} ip - The IP address to test.
348
+ * @returns {boolean} True if the IP is a loopback address, false otherwise.
349
+ */
160
350
  export function isLoopbackIp(ip) {
161
351
  if (!ip || typeof ip !== 'string') return true;
162
352
  let cleanIp = ip.trim().toLowerCase();
@@ -164,9 +354,16 @@ export function isLoopbackIp(ip) {
164
354
  cleanIp = cleanIp.substring(7);
165
355
  }
166
356
  return cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp === 'localhost' ||
167
- cleanIp.startsWith('127.') || cleanIp === '0.0.0.0' || cleanIp === '::';
357
+ cleanIp.startsWith('127.') || cleanIp === '0.0.0.0' || cleanIp === '::';
168
358
  }
169
359
 
360
+ /**
361
+ * Normalizes a referer URL by keeping only the protocol and hostname
362
+ * (e.g. "https://example.com").
363
+ *
364
+ * @param {string} referer - The raw referer URL.
365
+ * @returns {string} The normalized URL, or the original value if invalid.
366
+ */
170
367
  export function normalizeReferer(referer) {
171
368
  try {
172
369
  const url = new URL(referer);
@@ -176,6 +373,13 @@ export function normalizeReferer(referer) {
176
373
  }
177
374
  }
178
375
 
376
+ /**
377
+ * Checks whether an IP address belongs to a private range (RFC 1918, loopback,
378
+ * link-local, CGNAT, etc.). Handles both IPv4 and IPv6 formats.
379
+ *
380
+ * @param {string} ip - The IP address to test.
381
+ * @returns {boolean} True if the IP is private, false otherwise.
382
+ */
179
383
  export function isPrivateIp(ip) {
180
384
  if (!ip || typeof ip !== 'string') return false;
181
385
  let cleanIp = ip.trim().toLowerCase();
@@ -200,7 +404,16 @@ export function isPrivateIp(ip) {
200
404
  }
201
405
  return false;
202
406
  }
203
- // Utility parsing functions
407
+
408
+ /**
409
+ * Basic User-Agent parsing to extract:
410
+ * - the browser (with major version),
411
+ * - the operating system,
412
+ * - the device type (mobile, tablet, desktop).
413
+ *
414
+ * @param {string} ua - The User-Agent string.
415
+ * @returns {object} An object { browser?, os?, device }.
416
+ */
204
417
  export function parseUserAgent(ua) {
205
418
  // Basic User-Agent parser
206
419
  const result = {};
@@ -238,4 +451,41 @@ export function parseUserAgent(ua) {
238
451
  else result.device = 'desktop';
239
452
 
240
453
  return result;
454
+ }
455
+
456
+ /**
457
+ * Programmatically generates an Ed25519 key pair in PEM format
458
+ * (PKCS#8 for private key, SPKI for public key) matching OpenSSL CLI:
459
+ * `openssl genpkey -algorithm ed25519 -out issuer-private.pem`
460
+ * `openssl pkey -in issuer-private.pem -pubout -out issuer-public.pem`
461
+ *
462
+ * @param {string} [outDir='./config'] Target directory to store the PEM files.
463
+ * @param {object} [options={}] Optional configuration (e.g. custom file names).
464
+ * @returns {{privateKeyPath: string, publicKeyPath: string, privateKey: string, publicKey: string}}
465
+ */
466
+ export function generateIssuerPemKeys(outDir = './config', options = {}) {
467
+ const privateKeyName = options.privateKeyName || 'issuer-private.pem';
468
+ const publicKeyName = options.publicKeyName || 'issuer-public.pem';
469
+
470
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
471
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
472
+ publicKeyEncoding: { type: 'spki', format: 'pem' }
473
+ });
474
+
475
+ if (!fs.existsSync(outDir)) {
476
+ fs.mkdirSync(outDir, { recursive: true });
477
+ }
478
+
479
+ const privateKeyPath = path.join(outDir, privateKeyName);
480
+ const publicKeyPath = path.join(outDir, publicKeyName);
481
+
482
+ fs.writeFileSync(privateKeyPath, privateKey, { mode: 0o600 });
483
+ fs.writeFileSync(publicKeyPath, publicKey, { mode: 0o644 });
484
+
485
+ return {
486
+ privateKeyPath,
487
+ publicKeyPath,
488
+ privateKey,
489
+ publicKey
490
+ };
241
491
  }
@@ -10,7 +10,7 @@ self.onmessage = async (event) => {
10
10
  let solution = 0;
11
11
  const encoder = new TextEncoder();
12
12
  if (baseBlock) {
13
- // Mode solveCpuTargetInline (Uint8Array base block)
13
+ // solveCpuTargetInline mode (Uint8Array base block)
14
14
  const blockArray = new Uint8Array(baseBlock);
15
15
  while (true) {
16
16
  const solutionBytes = encoder.encode(String(solution));
@@ -31,7 +31,7 @@ self.onmessage = async (event) => {
31
31
  }
32
32
  }
33
33
  } else {
34
- // Mode solveCpuTarget standard/legacy (Message string)
34
+ // Standard/legacy solveCpuTarget mode (Message string)
35
35
  while (true) {
36
36
  const currentMessage = `${message}:${solution}`;
37
37
  const data = encoder.encode(currentMessage);
@@ -12,14 +12,14 @@ describe('Cross-Parity Chaos PoW Floating Point Parity (JS vs WASM vs PHP)', ()
12
12
  let wasmModule = null;
13
13
 
14
14
  beforeAll(async () => {
15
- // Essai de compilation WASM à la volée s'il y a em++
15
+ // Attempt on-the-fly WASM compilation if em++ toolchain is available
16
16
  try {
17
17
  execSync('node src/js/build-client.js', { stdio: 'ignore' });
18
18
  } catch (e) {
19
- // Ignorer si Emscripten n'est pas présent dans l'env
19
+ // Ignore if Emscripten is missing in environment
20
20
  }
21
21
 
22
- // Instancier le WASM réel si compilé
22
+ // Instantiate compiled WASM module if available
23
23
  if (fs.existsSync(fpJsPath) && fs.existsSync(fpWasmPath)) {
24
24
  try {
25
25
  const module = await import(fpJsPath);
@@ -9,15 +9,15 @@ describe('DNS Circuit Breaker', () => {
9
9
  dnsCircuitBreaker.failureCount = 0;
10
10
  dnsCircuitBreaker.lastStateChange = 0;
11
11
  dnsCircuitBreaker.threshold = 5;
12
- dnsCircuitBreaker.cooldownMs = 1000; // 1s de cooldown pour accélérer le test unitaire
12
+ dnsCircuitBreaker.cooldownMs = 1000; // 1s cooldown to speed up test execution
13
13
  });
14
14
 
15
- it('devrait démarrer à l\'état CLOSED et autoriser les résolutions', () => {
15
+ it('should start in CLOSED state and allow lookups', () => {
16
16
  expect(canAttemptDns()).toBe(true);
17
17
  expect(dnsCircuitBreaker.state).toBe('CLOSED');
18
18
  });
19
19
 
20
- it('devrait s\'ouvrir après avoir atteint le seuil d\'échecs (5)', () => {
20
+ it('should transition to OPEN after reaching failure threshold (5)', () => {
21
21
  for (let i = 0; i < 4; i++) {
22
22
  recordDnsFailure();
23
23
  expect(canAttemptDns()).toBe(true);
@@ -29,7 +29,7 @@ describe('DNS Circuit Breaker', () => {
29
29
  expect(dnsCircuitBreaker.state).toBe('OPEN');
30
30
  });
31
31
 
32
- it('devrait passer en HALF-OPEN après expiration du cooldown', async () => {
32
+ it('should transition to HALF-OPEN after cooldown expires', async () => {
33
33
  for (let i = 0; i < 5; i++) {
34
34
  recordDnsFailure();
35
35
  }
@@ -41,12 +41,12 @@ describe('DNS Circuit Breaker', () => {
41
41
  expect(dnsCircuitBreaker.state).toBe('HALF-OPEN');
42
42
  });
43
43
 
44
- it('devrait se refermer (CLOSED) après un succès en HALF-OPEN', async () => {
44
+ it('should reset to CLOSED after a success while in HALF-OPEN', async () => {
45
45
  for (let i = 0; i < 5; i++) {
46
46
  recordDnsFailure();
47
47
  }
48
48
  await new Promise((resolve) => setTimeout(resolve, 1100));
49
- expect(canAttemptDns()).toBe(true); // Passage en HALF-OPEN
49
+ expect(canAttemptDns()).toBe(true); // Transition to HALF-OPEN
50
50
 
51
51
  recordDnsSuccess();
52
52
  expect(dnsCircuitBreaker.state).toBe('CLOSED');
@@ -1,79 +1,77 @@
1
- import {describe, expect, it} from 'vitest';
2
- import {FingerprintBuilder} from '../fingerprint.builder.js';
3
-
4
- describe('FingerprintBuilder.compare', () => {
5
-
6
- // Empreinte réaliste d'un utilisateur légitime (ex: Chrome sur Windows)
7
- // C'est l'empreinte qui serait stockée lors de la première visite.
8
- const realisticOriginalFp = new FingerprintBuilder()
9
- .add('cvs', 'mock-canvas-data-v1')
10
- .add('gpu', 'ANGLE (NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0)')
11
- .add('hw', '16_8_0') // 16 cores, 8GB RAM, no touch
12
- .add('os', 'Win32')
13
- .add('ua', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36')
14
- .add('ja3', '771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0')
15
- // En-têtes qui peuvent changer lors de la requête de résolution du challenge
16
- .add('cookie_keys', '_ga,session,device_id')
17
- .add('upgrade', '1')
18
- .toString();
19
-
20
- it('should return a similarity score of 1.0 for the same device with minor volatile changes', () => {
21
- // Scénario de succès : L'utilisateur résout un challenge.
22
- // L'empreinte du solveur est presque identique, mais certains en-têtes "volatils"
23
- // (comme la présence de cookies ou 'upgrade-insecure-requests') ont changé ou disparu.
24
- // La fonction `compare` est conçue pour ignorer ces clés volatiles.
25
- const realisticSolverFp = new FingerprintBuilder()
26
- .add('cvs', 'mock-canvas-data-v1') // Identique
27
- .add('gpu', 'ANGLE (NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0)') // Identique
28
- .add('hw', '16_8_0') // Identique
29
- .add('os', 'Win32') // Identique
30
- .add('ua', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36') // Identique
31
- .add('ja3', '771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0') // Identique
32
- // La clé 'cookie_keys' est absente, simulant une requête sans cookies.
33
- // La clé 'upgrade' est également absente.
34
- .toString();
35
-
36
- const similarity = FingerprintBuilder.compare(realisticOriginalFp, realisticSolverFp);
37
-
38
- // La similarité doit être de 1.0 car toutes les différences concernent des clés volatiles
39
- // qui sont ignorées par la comparaison.
40
- expect(similarity).toBe(1.0);
41
- });
42
-
43
- it('should return a low similarity score for two completely different devices', () => {
44
- // Scénario d'échec : Un attaquant a volé le cookie `device_id` et tente de
45
- // résoudre un challenge depuis une machine différente (ex: un serveur Linux avec Firefox).
46
- const differentSolverFp = new FingerprintBuilder()
47
- .add('cvs', 'different-canvas-data') // Différent
48
- .add('gpu', 'llvmpipe (LLVM 15.0.7, 256 bits)') // Différent (GPU de VM)
49
- .add('hw', '8_4_0') // Différent
50
- .add('os', 'Linux x86_64') // Différent
51
- .add('ua', 'Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0') // Différent
52
- .add('ja3', '771,49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-156-157-47-53,65281-11-10-35-16-5-13-51-45-43-27-23-17513,29-23-24,0') // Différent (JA3 de Firefox)
53
- .toString();
54
-
55
- const similarity = FingerprintBuilder.compare(realisticOriginalFp, differentSolverFp);
56
-
57
- // La similarité doit être très faible (proche de 0) car tous les signaux forts sont différents.
58
- expect(similarity).toBeLessThan(0.1);
59
- });
60
-
61
- it('should return 0 if one of the fingerprints is null or empty', () => {
62
- expect(FingerprintBuilder.compare(realisticOriginalFp, null)).toBe(0);
63
- expect(FingerprintBuilder.compare(null, realisticOriginalFp)).toBe(0);
64
- expect(FingerprintBuilder.compare(realisticOriginalFp, '')).toBe(0);
65
- expect(FingerprintBuilder.compare('', realisticOriginalFp)).toBe(0);
66
- expect(FingerprintBuilder.compare(null, null)).toBe(0);
67
- });
68
-
69
- it('should handle fingerprints with missing components gracefully', () => {
70
- const partialFp = new FingerprintBuilder()
71
- .add('ua', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36')
72
- .add('os', 'Win32')
73
- .toString(); // Manque GPU, canvas, etc.
74
-
75
- const similarity = FingerprintBuilder.compare(realisticOriginalFp, partialFp);
76
- expect(similarity).toBeGreaterThan(0);
77
- expect(similarity).toBeLessThan(1);
78
- });
1
+ import {describe, expect, it} from 'vitest';
2
+ import {FingerprintBuilder} from '../fingerprint.builder.js';
3
+
4
+ describe('FingerprintBuilder.compare', () => {
5
+
6
+ // Realistic fingerprint for a legitimate user (e.g. Chrome on Windows)
7
+ // Recorded during baseline initial visit.
8
+ const realisticOriginalFp = new FingerprintBuilder()
9
+ .add('cvs', 'mock-canvas-data-v1')
10
+ .add('gpu', 'ANGLE (NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0)')
11
+ .add('hw', '16_8_0') // 16 cores, 8GB RAM, no touch
12
+ .add('os', 'Win32')
13
+ .add('ua', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36')
14
+ .add('ja3', '771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0')
15
+ // Volatile headers that may legitimately change during challenge solving
16
+ .add('cookie_keys', '_ga,session,device_id')
17
+ .add('upgrade', '1')
18
+ .toString();
19
+
20
+ it('should return a similarity score of 1.0 for the same device with minor volatile changes', () => {
21
+ // Success scenario: user solves a challenge.
22
+ // Solver fingerprint is identical except for volatile headers
23
+ // (such as cookie presence or upgrade-insecure-requests) which compare ignores.
24
+ const realisticSolverFp = new FingerprintBuilder()
25
+ .add('cvs', 'mock-canvas-data-v1') // Identique
26
+ .add('gpu', 'ANGLE (NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0)') // Identique
27
+ .add('hw', '16_8_0') // Identique
28
+ .add('os', 'Win32') // Identique
29
+ .add('ua', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36') // Identique
30
+ .add('ja3', '771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0') // Identique
31
+ // 'cookie_keys' is omitted, simulating a request without cookies
32
+ // 'upgrade' header is also omitted
33
+ .toString();
34
+
35
+ const similarity = FingerprintBuilder.compare(realisticOriginalFp, realisticSolverFp);
36
+
37
+ // Similarity remains 1.0 because all differences reside in ignored volatile keys
38
+ expect(similarity).toBe(1.0);
39
+ });
40
+
41
+ it('should return a low similarity score for two completely different devices', () => {
42
+ // Failure scenario: attacker steals device_id cookie and attempts
43
+ // challenge resolution from a different machine (e.g. Linux VM with Firefox).
44
+ const differentSolverFp = new FingerprintBuilder()
45
+ .add('cvs', 'different-canvas-data') // Différent
46
+ .add('gpu', 'llvmpipe (LLVM 15.0.7, 256 bits)') // Différent (GPU de VM)
47
+ .add('hw', '8_4_0') // Différent
48
+ .add('os', 'Linux x86_64') // Différent
49
+ .add('ua', 'Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0') // Différent
50
+ .add('ja3', '771,49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-156-157-47-53,65281-11-10-35-16-5-13-51-45-43-27-23-17513,29-23-24,0') // Différent (JA3 de Firefox)
51
+ .toString();
52
+
53
+ const similarity = FingerprintBuilder.compare(realisticOriginalFp, differentSolverFp);
54
+
55
+ // Similarity should be very low (close to 0) since invariant signals differ
56
+ expect(similarity).toBeLessThan(0.1);
57
+ });
58
+
59
+ it('should return 0 if one of the fingerprints is null or empty', () => {
60
+ expect(FingerprintBuilder.compare(realisticOriginalFp, null)).toBe(0);
61
+ expect(FingerprintBuilder.compare(null, realisticOriginalFp)).toBe(0);
62
+ expect(FingerprintBuilder.compare(realisticOriginalFp, '')).toBe(0);
63
+ expect(FingerprintBuilder.compare('', realisticOriginalFp)).toBe(0);
64
+ expect(FingerprintBuilder.compare(null, null)).toBe(0);
65
+ });
66
+
67
+ it('should handle fingerprints with missing components gracefully', () => {
68
+ const partialFp = new FingerprintBuilder()
69
+ .add('ua', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36')
70
+ .add('os', 'Win32')
71
+ .toString(); // Missing GPU, canvas, etc.
72
+
73
+ const similarity = FingerprintBuilder.compare(realisticOriginalFp, partialFp);
74
+ expect(similarity).toBeGreaterThan(0);
75
+ expect(similarity).toBeLessThan(1);
76
+ });
79
77
  });