@anonympins/fingerprint 0.3.4 → 0.3.6

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.
@@ -346,6 +346,30 @@ function getTlsFingerprint(context) {
346
346
  }
347
347
  return { ja3, ja4 };
348
348
  }
349
+
350
+ /**
351
+ * Analyses a raw JA3 string.
352
+ * Format: "TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurveFormats"
353
+ * @param {string} ja3String
354
+ * @returns {object|null}
355
+ */
356
+ export function parseJa3(ja3String) {
357
+ if (!ja3String || typeof ja3String !== 'string') {
358
+ return null;
359
+ }
360
+ const parts = ja3String.split(',');
361
+ if (parts.length !== 5) {
362
+ return null;
363
+ }
364
+ return {
365
+ tlsVersion: parseInt(parts[0], 10),
366
+ ciphers: parts[1] !== '' ? parts[1].split('-').map(Number) : [],
367
+ extensions: parts[2] !== '' ? parts[2].split('-').map(Number) : [],
368
+ curves: parts[3] !== '' ? parts[3].split('-').map(Number) : [],
369
+ points: parts[4] !== '' ? parts[4].split('-').map(Number) : []
370
+ };
371
+ }
372
+
349
373
  /**
350
374
  * Creates a stable hash based on device characteristics, independent of the IP.
351
375
  * This is our "level 2 fingerprint".
@@ -484,6 +508,17 @@ const tlsFingerprintDb = {
484
508
  'c72366b9551263d990b7fa574225332c': 'curl', // curl 7.81.0
485
509
  };
486
510
 
511
+ const GREASE_VALUES = [
512
+ 2570, 6682, 10794, 14906, 19018, 23130, 27242, 31354,
513
+ 35466, 39578, 43690, 47802, 51914, 55926, 60038, 64150
514
+ ];
515
+
516
+ /** @private */
517
+ function hasGrease(values) {
518
+ if (!Array.isArray(values)) return false;
519
+ return values.some(val => GREASE_VALUES.includes(val));
520
+ }
521
+
487
522
  // Fonctions utilitaires
488
523
  function parseUserAgent(ua) {
489
524
  // Parser basique du User-Agent
@@ -734,6 +769,8 @@ export const verifyPoWAndGenerateTicket = (
734
769
  nonce,
735
770
  solution,
736
771
  difficulty = 4,
772
+ deviceId = '',
773
+ deviceHash = ''
737
774
  ) => {
738
775
  // 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
739
776
  const hash = crypto
@@ -749,10 +786,10 @@ export const verifyPoWAndGenerateTicket = (
749
786
  const expiry = Date.now() + 3600000; // 1 heure
750
787
  const signature = crypto
751
788
  .createHmac("sha256", getPowSecret())
752
- .update(`${ip}:${expiry}`)
789
+ .update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
753
790
  .digest("hex");
754
791
 
755
- return `${expiry}:${signature}`;
792
+ return `${expiry}|${ip}|${signature}`;
756
793
  };
757
794
 
758
795
 
@@ -787,23 +824,61 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
787
824
  }
788
825
  return finalHash === parseInt(solution, 10);
789
826
  };
790
- export const isTicketValid = (ip, ticket) => {
827
+ export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
791
828
  // Input validation: ensure the ticket is a non-empty string with the correct format.
792
- if (typeof ticket !== 'string' || !ticket.includes(':')) return false;
793
- const [expiry, sig] = ticket.split(':');
829
+ if (typeof ticket !== 'string') return false;
830
+
831
+ let expiry, originalIp, sig;
832
+ if (ticket.includes('|')) {
833
+ const parts = ticket.split('|');
834
+ if (parts.length < 3) return false;
835
+ [expiry, originalIp, sig] = parts;
836
+ } else if (ticket.includes(':')) {
837
+ // Legacy fallback format
838
+ const parts = ticket.split(':');
839
+ if (parts.length < 2) return false;
840
+ [expiry, sig] = parts;
841
+ originalIp = ip;
842
+ } else {
843
+ return false;
844
+ }
845
+
794
846
  if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
795
- const expectedSig = crypto
796
- .createHmac("sha256", getPowSecret())
797
- .update(`${ip}:${expiry}`)
798
- .digest("hex");
847
+
848
+ let expectedSig;
849
+ if (ticket.includes('|')) {
850
+ expectedSig = crypto
851
+ .createHmac("sha256", getPowSecret())
852
+ .update(`${expiry}:${originalIp}:${deviceId}:${deviceHash}`)
853
+ .digest("hex");
854
+ } else {
855
+ // Legacy expected signature
856
+ expectedSig = crypto
857
+ .createHmac("sha256", getPowSecret())
858
+ .update(`${ip}:${expiry}`)
859
+ .digest("hex");
860
+ }
799
861
 
800
862
  // Use timingSafeEqual to prevent timing attacks
801
863
  try {
802
- return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
864
+ const isSigValid = crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
865
+ if (!isSigValid) return false;
803
866
  } catch (e) {
804
- // This can happen if the buffers have different lengths, which is a failure case.
805
867
  return false;
806
868
  }
869
+
870
+ if (!ticket.includes('|')) {
871
+ return ip === originalIp;
872
+ }
873
+
874
+ // Roaming & Terminal Identity checks:
875
+ if (ip === originalIp) return true;
876
+ const currentSubnet = getIpSubnet(ip);
877
+ const originalSubnet = getIpSubnet(originalIp);
878
+ if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
879
+
880
+ // Perfect terminal identity matched via HMAC signature
881
+ return !!(deviceId && deviceHash);
807
882
  };
808
883
 
809
884
 
@@ -814,8 +889,9 @@ export const isTicketValid = (ip, ticket) => {
814
889
  */
815
890
  function getHeaderAnomalies(context) {
816
891
  let anomalyScore = 0;
892
+ const ua = context.headers["user-agent"] || '';
817
893
  // Strong penalty if User-Agent is missing or very short (sign of a simple script)
818
- if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
894
+ if (!ua || ua.length < 10) {
819
895
  anomalyScore += 60;
820
896
  }
821
897
  // Penalty if Accept-Language header is missing
@@ -827,6 +903,17 @@ function getHeaderAnomalies(context) {
827
903
  anomalyScore += 15;
828
904
  }
829
905
 
906
+ // TE: trailers check for Firefox on Desktop
907
+ const uaParts = parseUserAgent(ua);
908
+ const isFirefoxDesktop = uaParts.browser?.startsWith('Firefox') && uaParts.device === 'desktop';
909
+ const teHeader = context.headers['te'];
910
+
911
+ if (isFirefoxDesktop && teHeader !== 'trailers') {
912
+ anomalyScore += 30; // Suspicious: Firefox desktop missing TE: trailers
913
+ } else if (!isFirefoxDesktop && uaParts.device === 'desktop' && teHeader === 'trailers') {
914
+ anomalyScore += 30; // Suspicious: Non-Firefox desktop sending TE: trailers
915
+ }
916
+
830
917
  return {
831
918
  headerAnomalyScore: Math.min(100, anomalyScore),
832
919
  };
@@ -1142,21 +1229,130 @@ function getCrossLayerInconsistency(context) {
1142
1229
  return { crossLayerInconsistencyScore: 10 }; // Erreur de parsing = suspect.
1143
1230
  }
1144
1231
  }
1232
+ function parseJa4(ja4) {
1233
+ if (!ja4 || typeof ja4 !== 'string') return null;
1234
+ const parts = ja4.split('_');
1235
+ const ja4a = parts[0];
1236
+ if (ja4a.length < 10) return null;
1237
+ return {
1238
+ protocol: ja4a[0],
1239
+ version: ja4a.substring(1, 3),
1240
+ sni: ja4a[3],
1241
+ ciphersCount: parseInt(ja4a.substring(4, 6), 10) || 0,
1242
+ extensionsCount: parseInt(ja4a.substring(6, 8), 10) || 0,
1243
+ alpn: ja4a.substring(8, 10),
1244
+ ja4b: parts[1] || null,
1245
+ ja4c: parts[2] || null
1246
+ };
1247
+ }
1145
1248
 
1146
1249
  /**
1147
1250
  * Calcule un score d'incohérence entre les données du fingerprint TLS (JA3/JA4) et les en-têtes serveur (User-Agent).
1148
1251
  * Cela permet de détecter le spoofing de fingerprint TLS.
1149
1252
  * @param {object} context - Le contexte de la requête.
1150
- * @returns {{tlsSpoofingScore: number}}
1253
+ * @returns {Promise<{tlsSpoofingScore: number}>}
1151
1254
  */
1152
- function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
1153
- const { ja3, ja4 } = getTlsFingerprintFn(context) || { ja3: null, ja4: null }; // Defensive check
1255
+ export function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint, customStore = null) {
1256
+ let actualGetTlsFingerprintFn = getTlsFingerprintFn;
1257
+ let actualStore = customStore || store;
1258
+
1259
+ // Detect if the second argument is actually a store (compatibility with tests)
1260
+ if (getTlsFingerprintFn && typeof getTlsFingerprintFn.get === 'function' && typeof getTlsFingerprintFn.set === 'function') {
1261
+ actualStore = getTlsFingerprintFn;
1262
+ actualGetTlsFingerprintFn = getTlsFingerprint;
1263
+ }
1264
+
1265
+ const { ja3, ja4 } = actualGetTlsFingerprintFn(context) || { ja3: null, ja4: null }; // Defensive check
1154
1266
  const ua = context.headers["user-agent"] || '';
1267
+ const ja3Raw = context.headers['x-ja3-raw'] || null;
1268
+ const httpVersion = context.httpVersion || '';
1269
+
1270
+ let score = 0;
1155
1271
 
1156
1272
  // 1. Penalize if a TLS fingerprint is present but the User-Agent is generic or missing.
1157
1273
  // This is a strong indicator of a non-browser client trying to look legitimate.
1158
1274
  if ((ja3 || ja4) && (!ua || ua.length < 10 || ua.toLowerCase().includes('python') || ua.toLowerCase().includes('curl'))) {
1159
- return { tlsSpoofingScore: 50 };
1275
+ score = Math.max(score, 50);
1276
+ }
1277
+
1278
+ // Check for known spoofed/suspicious JA4 fingerprints
1279
+ const spoofedJa4s = [
1280
+ 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
1281
+ 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
1282
+ 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
1283
+ 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
1284
+ 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
1285
+ 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
1286
+ ];
1287
+ if (ja4 && spoofedJa4s.includes(ja4)) {
1288
+ score = Math.max(score, 100);
1289
+ }
1290
+
1291
+ const claimedBrowser = parseUserAgent(ua).browser?.split('/')[0] || null;
1292
+ const isHumanBrowser = ['Chrome', 'Firefox', 'Safari', 'Edge'].includes(claimedBrowser);
1293
+
1294
+ // --- ANALYSE 2 : CONTRÔLE PROFOND SUR L'EMPREINTE BRUTE (RAW JA3) ---
1295
+ if (ja3Raw) {
1296
+ const parsed = parseJa3(ja3Raw);
1297
+ if (parsed) {
1298
+ // Contrôle A : Mécanisme GREASE pour Chrome / Edge (obligatoire)
1299
+ if (claimedBrowser === 'Chrome' || claimedBrowser === 'Edge') {
1300
+ const hasCiphersGrease = hasGrease(parsed.ciphers);
1301
+ const hasExtensionsGrease = hasGrease(parsed.extensions);
1302
+
1303
+ if (!hasCiphersGrease && !hasExtensionsGrease) {
1304
+ // Chrome ou Edge moderne sans GREASE = spoofing de bas niveau (ex: python-requests déguisé)
1305
+ score = Math.max(score, 75);
1306
+ }
1307
+ }
1308
+
1309
+ // Contrôle B : HTTP/2 ou HTTP/3 sans négociation ALPN (Extension 16)
1310
+ const isH2OrHigher = (
1311
+ httpVersion.includes('2.0') ||
1312
+ httpVersion.includes('HTTP/2') ||
1313
+ httpVersion.includes('HTTP/3')
1314
+ );
1315
+ const hasAlpnExtension = parsed.extensions.includes(16);
1316
+
1317
+ if (isH2OrHigher && !hasAlpnExtension) {
1318
+ // Négociation HTTP/2 active au niveau serveur mais absente au niveau des extensions TLS du client
1319
+ score = Math.max(score, 70);
1320
+ }
1321
+
1322
+ // Contrôle C : Version TLS obsolète négociée par un navigateur moderne (ex: TLS < 1.2, id < 771)
1323
+ if (isHumanBrowser && parsed.tlsVersion < 771) {
1324
+ score = Math.max(score, 80);
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ // Parse JA4 if available for advanced checks
1330
+ if (ja4) {
1331
+ const parsedJa4 = parseJa4(ja4);
1332
+ if (parsedJa4) {
1333
+ const uaParts = parseUserAgent(ua);
1334
+
1335
+ // Check 1: Incohérence ALPN / HTTP Version
1336
+ if (parsedJa4.alpn === 'h2' && (context.httpVersion === '1.1' || context.httpVersion === '1.0')) {
1337
+ const hasProxy = context.headers['via'] || context.headers['forwarded'] || context.headers['x-forwarded-proto'] || context.headers['x-forwarded-for'];
1338
+ if (!hasProxy) {
1339
+ score = Math.max(score, 40);
1340
+ }
1341
+ }
1342
+
1343
+ // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
1344
+ if (parsedJa4.version === '12' && (uaParts.os === 'iOS' || uaParts.os === 'macOS') && uaParts.browser?.startsWith('Safari')) {
1345
+ score = Math.max(score, 60);
1346
+ }
1347
+
1348
+ // Check 3: Incohérence User-Agent vs Signature JA4
1349
+ if (uaParts.browser?.startsWith('Chrome') && parsedJa4.alpn === '00') {
1350
+ score = Math.max(score, 50);
1351
+ }
1352
+ if (uaParts.browser?.startsWith('Firefox') && parsedJa4.extensionsCount > 15) {
1353
+ score = Math.max(score, 50);
1354
+ }
1355
+ }
1160
1356
  }
1161
1357
 
1162
1358
  // 2. If no JA3 hash is available, we cannot perform the consistency check.
@@ -1173,23 +1369,72 @@ function getTlsSpoofingScore(context, getTlsFingerprintFn = getTlsFingerprint) {
1173
1369
  // Parse the User-Agent to get the claimed browser.
1174
1370
  const { browser: claimedBrowser } = parseUserAgent(ua);
1175
1371
 
1176
- // Check if the claimed browser is one of the legitimate possibilities for this JA3 hash.
1177
- // We use `some` to see if the claimed browser starts with any of the expected browser names.
1178
- // (e.g., "Chrome/116" starts with "Chrome").
1179
- const isMatch = expectedBrowsers.some(expected => claimedBrowser?.startsWith(expected));
1180
-
1181
- if (claimedBrowser && !isMatch) {
1182
- return { tlsSpoofingScore: 80 }; // High score for a clear mismatch.
1372
+ // Check 1: Known library JA3 with a human-claimed browser
1373
+ const isLibrary = expectedBrowsers.some(expected => ['Python', 'Go', 'Java', 'curl'].includes(expected));
1374
+ const claimsToBeHumanBrowser = claimedBrowser && (
1375
+ claimedBrowser.startsWith('Chrome') ||
1376
+ claimedBrowser.startsWith('Firefox') ||
1377
+ claimedBrowser.startsWith('Safari') ||
1378
+ claimedBrowser.startsWith('Edge')
1379
+ );
1380
+
1381
+ if (isLibrary && claimsToBeHumanBrowser) {
1382
+ score = Math.max(score, 90); // High confidence spoofing of library as browser
1383
+ } else {
1384
+ // Check if the claimed browser is one of the legitimate possibilities for this JA3 hash.
1385
+ const isMatch = expectedBrowsers.some(expected => claimedBrowser?.startsWith(expected));
1386
+ if (claimedBrowser && !isMatch) {
1387
+ score = Math.max(score, 80); // High score for a clear mismatch.
1388
+ }
1183
1389
  }
1184
1390
  }
1185
1391
  }
1186
1392
 
1187
- // If we reach here, either:
1188
- // - No JA3 was available.
1189
- // - The JA3 was not in our database (we can't make a decision).
1190
- // - The JA3 and User-Agent were consistent.
1191
- // In all these cases, the score is 0.
1192
- return { tlsSpoofingScore: 0 };
1393
+ // Create the promise for the async part (Check 4)
1394
+ const promise = (async () => {
1395
+ let asyncScore = score;
1396
+ const uaParts = parseUserAgent(ua);
1397
+
1398
+ if (uaParts.browser) {
1399
+ const browserFamily = uaParts.browser.split('/')[0];
1400
+
1401
+ // Check 4a: Stagnation JA4
1402
+ if (ja4) {
1403
+ const parsedJa4 = parseJa4(ja4);
1404
+ if (parsedJa4) {
1405
+ const ja4Key = `ja4-browsers:${ja4}`;
1406
+ let seenBrowsers = await actualStore.get(ja4Key) || [];
1407
+ if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
1408
+ if (browserFamily && !seenBrowsers.includes(browserFamily)) {
1409
+ seenBrowsers.push(browserFamily);
1410
+ await actualStore.set(ja4Key, seenBrowsers, 86400); // 24h cache
1411
+ }
1412
+ if (seenBrowsers.length > 1) {
1413
+ asyncScore = Math.max(asyncScore, 80);
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ // Check 4b: JA3 MD5 stagnation with rotating browser UAs
1419
+ if (ja3 && browserFamily) {
1420
+ const ja3Key = `ja3-browsers:${ja3}`;
1421
+ let seenBrowsers = await actualStore.get(ja3Key) || [];
1422
+ if (!Array.isArray(seenBrowsers)) seenBrowsers = [];
1423
+ if (!seenBrowsers.includes(browserFamily)) {
1424
+ seenBrowsers.push(browserFamily);
1425
+ await actualStore.set(ja3Key, seenBrowsers, 86400); // 24h cache
1426
+ }
1427
+ if (seenBrowsers.length > 1) {
1428
+ asyncScore = Math.max(asyncScore, 85); // Staging different UAs on same JA3 MD5 signature
1429
+ }
1430
+ }
1431
+ }
1432
+ return { tlsSpoofingScore: asyncScore };
1433
+ })();
1434
+
1435
+ // Decorate the promise so synchronous calls can destructure it!
1436
+ promise.tlsSpoofingScore = score;
1437
+ return promise;
1193
1438
  }
1194
1439
 
1195
1440
  /**
@@ -1501,7 +1746,7 @@ function getBotScore(context) {
1501
1746
  }
1502
1747
  } catch (e) { /* Ignorer les erreurs de parsing */ }
1503
1748
 
1504
- return { botScore: 0 };
1749
+ return { botScore: 0 };
1505
1750
  }
1506
1751
 
1507
1752
  /**
@@ -1573,6 +1818,22 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1573
1818
  }
1574
1819
  }
1575
1820
 
1821
+ // Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
1822
+ let enumerationScore = 0;
1823
+ if (history.length >= 3) {
1824
+ const templates = history.map(h => h.path.replace(/\d+/g, '{num}'));
1825
+ const uniquePaths = new Set(history.map(h => h.path));
1826
+
1827
+ const templateCounts = {};
1828
+ templates.forEach(t => templateCounts[t] = (templateCounts[t] || 0) + 1);
1829
+
1830
+ const maxTemplateRepetition = Math.max(...Object.values(templateCounts), 0);
1831
+ // Si une même structure de route est répétée mais sur des URLs réelles différentes
1832
+ if (maxTemplateRepetition >= 3 && uniquePaths.size === history.length) {
1833
+ enumerationScore = patternWeight * 0.8; // Appliquer une forte pénalité
1834
+ }
1835
+ }
1836
+
1576
1837
  // Garder l'historique à une taille raisonnable
1577
1838
  if (history.length > historySize) {
1578
1839
  history.shift();
@@ -1591,7 +1852,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1591
1852
  }
1592
1853
  newPatternScore = Math.max(0, newPatternScore);
1593
1854
 
1594
- deviceData.lastPatternScore = newPatternScore + instantScore;
1855
+ deviceData.lastPatternScore = newPatternScore + instantScore + enumerationScore;
1595
1856
 
1596
1857
  return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
1597
1858
  }
@@ -1854,7 +2115,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
1854
2115
  // On appelle getHoneypotScore ici pour que son résultat soit inclus dans le vecteur.
1855
2116
  const { honeypotScore } = getHoneypotScore(context, honeypotConfig);
1856
2117
 
1857
- const { tlsSpoofingScore } = getTlsSpoofingScore(context);
2118
+ const { tlsSpoofingScore } = await getTlsSpoofingScore(context);
1858
2119
 
1859
2120
  const { botScore } = getBotScore(context);
1860
2121
 
@@ -2136,6 +2397,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
2136
2397
  nonce,
2137
2398
  solution,
2138
2399
  challengeContext = {}, // Le contexte complet du challenge est maintenant passé
2400
+ deviceId = '',
2401
+ deviceHash = ''
2139
2402
  ) {
2140
2403
  const { cpuTarget, baseBlock } = challengeContext;
2141
2404
  if (!cpuTarget || !baseBlock) {
@@ -2187,9 +2450,9 @@ export function verifyCpuTargetPoWAndGenerateTicket(
2187
2450
  const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
2188
2451
  const signature = crypto
2189
2452
  .createHmac("sha256", getPowSecret())
2190
- .update(`${clientIp}:${expiry}`)
2453
+ .update(`${expiry}:${clientIp}:${deviceId}:${deviceHash}`)
2191
2454
  .digest("hex");
2192
- return `${expiry}:${signature}`;
2455
+ return `${expiry}|${clientIp}|${signature}`;
2193
2456
  }
2194
2457
 
2195
2458
  return null;
@@ -2269,7 +2532,7 @@ export class FingerprintEngine {
2269
2532
  console.log(`[FingerprintEngine] ${message}`, data);
2270
2533
  }
2271
2534
  }
2272
- calculateFinalScore = function(suspicionVector) {
2535
+ calculateFinalScore(suspicionVector) {
2273
2536
  const { weights } = this.securityConfig;
2274
2537
  if (!weights) return 0;
2275
2538
 
@@ -2523,8 +2786,15 @@ export class FingerprintEngine {
2523
2786
  }
2524
2787
 
2525
2788
  // 2. Forward DNS lookup
2526
- const addresses = await dns.resolve(validHostname);
2527
- if (addresses.includes(clientIp)) {
2789
+ let addresses = [];
2790
+ try {
2791
+ addresses = await dns.resolve(validHostname);
2792
+ } catch (e) {}
2793
+ try {
2794
+ const ipv6 = await dns.resolve(validHostname, 'AAAA');
2795
+ addresses = addresses.concat(ipv6);
2796
+ } catch (e) {}
2797
+ if (addresses.includes(clientIp)) {
2528
2798
  await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
2529
2799
  return true;
2530
2800
  }
@@ -2548,6 +2818,11 @@ export class FingerprintEngine {
2548
2818
  return { action: 'next', score: 0, vector: {} };
2549
2819
  }
2550
2820
 
2821
+ // Resolve identity and check for persisted "condemned" status early.
2822
+ const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
2823
+ const currentDeviceHash = getCompositeDeviceHash(requestContext);
2824
+ const isNewDevice = !!newCookie;
2825
+
2551
2826
  // 1. Check static IP allowlist first for maximum performance.
2552
2827
  if (this._isIpInAllowlist(clientIp)) {
2553
2828
  this._log('IP in allowlist - allowing request', { clientIp });
@@ -2586,7 +2861,7 @@ export class FingerprintEngine {
2586
2861
  // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
2587
2862
  if (pow_nonce) {
2588
2863
  const powCookie = cookies?.pow_clearance;
2589
- if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
2864
+ if (!isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash)) { // Only check if there's no valid ticket
2590
2865
  // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
2591
2866
  // The final decision is made later, after calculating the score.
2592
2867
  }
@@ -2598,10 +2873,6 @@ export class FingerprintEngine {
2598
2873
  return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
2599
2874
  }
2600
2875
 
2601
- // Resolve identity and check for persisted "condemned" status early.
2602
- const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
2603
- const isNewDevice = !!newCookie;
2604
-
2605
2876
  this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
2606
2877
 
2607
2878
  if (deviceData?.condemned) {
@@ -2696,7 +2967,26 @@ export class FingerprintEngine {
2696
2967
  });
2697
2968
 
2698
2969
  let isValid = false;
2699
- const challengeContext = await store.get(`secret:${pow_nonce}`);
2970
+ let challengeContext = await store.get(`secret:${pow_nonce}`);
2971
+
2972
+ // SECURITY: Verify that the retrieved context has not been tampered with
2973
+ if (challengeContext && challengeContext.signature) {
2974
+ const payloadToSign = `${challengeContext.clientSecret}:${challengeContext.cpuTarget}:${challengeContext.fingerprint}:${challengeContext.memDifficulty}:${challengeContext.originalPath}:${clientIp}`;
2975
+ const expectedSignature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
2976
+ try {
2977
+ const isSignatureValid = crypto.timingSafeEqual(
2978
+ Buffer.from(challengeContext.signature, 'hex'),
2979
+ Buffer.from(expectedSignature, 'hex')
2980
+ );
2981
+ if (!isSignatureValid) {
2982
+ this._log('Challenge context signature invalid - storage tampering detected!', { nonce: pow_nonce });
2983
+ challengeContext = null; // Invalidate context immediately
2984
+ }
2985
+ } catch (e) {
2986
+ this._log('Error validating challenge context signature:', e);
2987
+ challengeContext = null;
2988
+ }
2989
+ }
2700
2990
  let ticket = null;
2701
2991
  // Déclarer optimalTtl ici avec une valeur par défaut
2702
2992
  let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
@@ -2745,11 +3035,12 @@ export class FingerprintEngine {
2745
3035
 
2746
3036
  if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
2747
3037
  const cpuSolution = pow_solution_cpu || pow_solution;
2748
- ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext);
3038
+ ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
2749
3039
  isValid = ticket !== null;
2750
3040
  this._log('CPU target challenge verification', { isValid });
2751
- } else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) { const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext);
2752
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
3041
+ } else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
3042
+ const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
3043
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
2753
3044
  isValid = cpuTicket !== null && isMemValid;
2754
3045
  if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
2755
3046
  this._log('Combined CPU+Memory challenge verification', {
@@ -2875,7 +3166,10 @@ export class FingerprintEngine {
2875
3166
  if (challengeContext) {
2876
3167
  try {
2877
3168
  const workResult = JSON.parse(pow_solution_work_result);
2878
- getProblemManager(this.securityConfig.usefulWorkConfigPath).integrateSolution(pow_problem_id, workResult);
3169
+ getProblemManager({
3170
+ configPath: this.securityConfig.usefulWorkConfigPath,
3171
+ config: this.securityConfig.usefulWorkConfig
3172
+ }, store).integrateSolution(pow_problem_id, workResult);
2879
3173
 
2880
3174
  await store.delete(`secret:${pow_nonce}`);
2881
3175
  // Accorder un ticket de passage comme pour un PoW normal
@@ -2941,7 +3235,7 @@ export class FingerprintEngine {
2941
3235
  // 1. La requête est suspecte ET il n'y a pas de ticket valide.
2942
3236
  // OU
2943
3237
  // 2. La requête est *très* suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
2944
- const hasValidTicket = isTicketValid(clientIp, powCookie);
3238
+ const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
2945
3239
  const mustReChallenge = isSuspiciousHigh && hasValidTicket;
2946
3240
 
2947
3241
  if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
@@ -2985,7 +3279,10 @@ export class FingerprintEngine {
2985
3279
  if (isSuspicious && shouldUseUsefulWork) {
2986
3280
  this._log('Issuing a useful work challenge', { finalScore });
2987
3281
 
2988
- const { problemId, task } = getProblemManager(this.securityConfig.usefulWorkConfigPath).dispatchWork(suspicionFactor);
3282
+ const { problemId, task } = getProblemManager({
3283
+ configPath: this.securityConfig.usefulWorkConfigPath,
3284
+ config: this.securityConfig.usefulWorkConfig
3285
+ }, store).dispatchWork(suspicionFactor);
2989
3286
 
2990
3287
  await store.set(`secret:${nonce}`, { clientSecret, originalPath: path }, 300);
2991
3288
 
@@ -3033,6 +3330,11 @@ export class FingerprintEngine {
3033
3330
 
3034
3331
  // Store the entire challenge context with a short TTL (e.g., 5 minutes)
3035
3332
  const baseBlock = createCpuChallengeBaseBlock(nonce, clientSecret, originalFingerprint);
3333
+
3334
+ // SECURITY: Cryptographically sign the payload before storing it to prevent database tampering
3335
+ const payloadToSign = `${clientSecret}:${cpuChallengeDetails.target}:${originalFingerprint}:${memDifficulty}:${path}:${clientIp}`;
3336
+ const signature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
3337
+
3036
3338
  await store.set(`secret:${nonce}`, {
3037
3339
  clientSecret,
3038
3340
  cpuTarget: cpuChallengeDetails.target,
@@ -3041,6 +3343,7 @@ export class FingerprintEngine {
3041
3343
  memDifficulty: memDifficulty,
3042
3344
  baseBlock: baseBlock, // *** NOUVEAU: Le bloc de base est stocké pour la vérification ***
3043
3345
  originalPath: path, // *** FIX: Store the original path ***
3346
+ signature, // *** NOUVEAU: Cryptographic signature to prevent storage tampering ***
3044
3347
  }, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
3045
3348
 
3046
3349
  // Associate the current challenge nonce with the device for trap URL verification later.
@@ -3090,7 +3393,7 @@ export class FingerprintEngine {
3090
3393
  }
3091
3394
 
3092
3395
  // Basic log for each non-static request that passed without a challenge
3093
- this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
3396
+ this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash) });
3094
3397
 
3095
3398
  if (logger) {
3096
3399
  logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
@@ -3539,7 +3842,9 @@ export const __internal = {
3539
3842
  getTimeInconsistencyScore,
3540
3843
  getClickVarianceScore, // NOUVEAU: Expose pour les tests
3541
3844
  getTlsFingerprint, // NOUVEAU: Expose pour les tests
3845
+ sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
3542
3846
  getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
3847
+ parseJa3,
3543
3848
  generateCpuTargetChallengePage,
3544
3849
  getClientHintsInconsistencyScore, // Expose for testing
3545
3850
  generateCombinedPoWChallengePage,
@@ -3556,25 +3861,60 @@ export const __internal = {
3556
3861
  let autoTuningJobId = null;
3557
3862
  let lastBestSolution = null; // NOUVEAU: Stocke la meilleure solution trouvée
3558
3863
 
3864
+ /**
3865
+ * Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
3866
+ * Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
3867
+ * @export
3868
+ * @param {Array<object>} trafficData
3869
+ * @returns {Array<object>}
3870
+ */
3871
+ export function sanitizeTrafficData(trafficData) {
3872
+ if (!trafficData || trafficData.length === 0) {
3873
+ return [];
3874
+ }
3875
+ const tempSanitized = [];
3876
+ const deviceCounts = new Map();
3877
+ const maxLogsPerDevice = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% contribution per device
3878
+
3879
+ for (const log of trafficData) {
3880
+ const devId = log.deviceId || 'anonymous';
3881
+ const currentCount = deviceCounts.get(devId) || 0;
3882
+ if (currentCount < maxLogsPerDevice) {
3883
+ deviceCounts.set(devId, currentCount + 1);
3884
+ tempSanitized.push(log);
3885
+ }
3886
+ }
3887
+
3888
+ const passedLogs = tempSanitized.filter(log => log.type === 'request_passed');
3889
+ const suspiciousLogs = tempSanitized.filter(log => log.type !== 'request_passed');
3890
+
3891
+ const minDataPoints = 200; // Seuil par défaut
3892
+ const maxPassedAllowed = Math.max(minDataPoints, suspiciousLogs.length * 9);
3893
+ const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
3894
+ const selectedPassed = shuffledPassed.slice(0, maxPassedAllowed);
3895
+
3896
+ return [...suspiciousLogs, ...selectedPassed];
3897
+ }
3898
+
3559
3899
  /**
3560
3900
  * Executes a threshold optimization pass using collected traffic data.
3561
3901
  * @private
3562
- * @param {object} securityConfig - The security configuration object to update.
3563
- * @param {Array<object>} trafficData - The array containing traffic logs.
3564
- * @param {number} minDataPoints - The minimum number of data points required to start optimization.
3565
- * @param {number} maxDataPoints - The maximum number of data points to keep after an optimization cycle.
3566
- * @param {string} [savePath] - Optional path to save the best configuration to a file.
3567
3902
  */
3568
3903
  function runThresholdOptimization(securityConfig, trafficData, minDataPoints, maxDataPoints, savePath) {
3569
- const highConfidenceLogs = trafficData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
3570
- const highConfidenceRatio = trafficData.length > 0 ? highConfidenceLogs / trafficData.length : 0;
3904
+ const sanitizedData = sanitizeTrafficData(trafficData);
3905
+
3906
+ const highConfidenceLogs = sanitizedData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
3907
+ const highConfidenceRatio = sanitizedData.length > 0 ? highConfidenceLogs / sanitizedData.length : 0;
3571
3908
  const MIN_CONFIDENCE_RATIO = 0.05; // Exiger au moins 5% de signaux forts.
3909
+ const MIN_HIGH_CONFIDENCE_COUNT = 10; // Absolu de secours pour éviter le gel lors de floods
3910
+
3911
+ const hasEnoughSignal = highConfidenceRatio >= MIN_CONFIDENCE_RATIO || highConfidenceLogs >= MIN_HIGH_CONFIDENCE_COUNT;
3572
3912
 
3573
- if (trafficData.length < minDataPoints || highConfidenceRatio < MIN_CONFIDENCE_RATIO) {
3574
- if (trafficData.length < minDataPoints) {
3575
- console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
3913
+ if (sanitizedData.length < minDataPoints || !hasEnoughSignal) {
3914
+ if (sanitizedData.length < minDataPoints) {
3915
+ console.log(`[AutoTuning] Reporté : ${sanitizedData.length}/${minDataPoints} points de données.`);
3576
3916
  } else {
3577
- console.log(`[AutoTuning] Reporté : Ratio de confiance insuffisant (${(highConfidenceRatio * 100).toFixed(2)}% < ${(MIN_CONFIDENCE_RATIO * 100).toFixed(2)}%).`);
3917
+ console.log(`[AutoTuning] Reporté : Signaux de confiance insuffisants (Ratio: ${(highConfidenceRatio * 100).toFixed(2)}% < ${(MIN_CONFIDENCE_RATIO * 100).toFixed(2)}% et absolu: ${highConfidenceLogs} < ${MIN_HIGH_CONFIDENCE_COUNT}).`);
3578
3918
  }
3579
3919
  return;
3580
3920
  }
@@ -3584,9 +3924,9 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints, ma
3584
3924
  trafficData.splice(0, trafficData.length - maxDataPoints);
3585
3925
  }
3586
3926
 
3587
- console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${trafficData.length} points de données.`);
3927
+ console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${sanitizedData.length} points de données assainis.`);
3588
3928
 
3589
- const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData });
3929
+ const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData: sanitizedData });
3590
3930
 
3591
3931
  if (!paretoFront || paretoFront.length === 0) {
3592
3932
  console.warn("[AutoTuning] L'optimisation n'a retourné aucune solution.");