@anonympins/fingerprint 0.4.3 → 0.4.5

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.
@@ -90,6 +90,7 @@ class RequestUtils
90
90
  "ch_model" => "sec-ch-ua-model",
91
91
  "ch_arch" => "sec-ch-ua-arch",
92
92
  "ch_bitness" => "sec-ch-ua-bitness",
93
+ "ch_full_version_list" => "sec-ch-ua-full-version-list",
93
94
  "upgrade_req" => "upgrade-insecure-requests",
94
95
  "accept_lang" => "accept-language",
95
96
  "accept_enc" => "accept-encoding",
@@ -289,7 +290,105 @@ class RequestUtils
289
290
 
290
291
  return ['avgSpeed' => $avgSpeed, 'avgAcceleration' => $avgAcceleration, 'straightness' => $straightness, 'pauses' => $pauses, 'segments' => array_column($segments, 'distance')];
291
292
  }
293
+ /**
294
+ * Analyse une série d'événements tactiles mobiles pour en extraire des indicateurs comportementaux.
295
+ * @param array|null $history
296
+ * @return array
297
+ */
298
+ private static function analyzeTouchMovements(?array $history): array
299
+ {
300
+ if (empty($history) || count($history) < 3) {
301
+ return [
302
+ 'avgSpeed' => 0.0, 'avgAcceleration' => 0.0, 'straightness' => 1.0, 'pauses' => 0, 'segments' => [],
303
+ 'avgPressure' => 0.0, 'avgRadius' => 0.0, 'pressureVariance' => 0.0, 'radiusVariance' => 0.0, 'maxTouches' => 1
304
+ ];
305
+ }
306
+
307
+ $segments = [];
308
+ $totalDistance = 0.0;
309
+ $pauses = 0;
310
+ $totalPressure = 0.0;
311
+ $totalRadius = 0.0;
312
+ $maxTouches = 1;
313
+
314
+ for ($i = 1; $i < count($history); $i++) {
315
+ $p1 = $history[$i - 1];
316
+ $p2 = $history[$i];
317
+ $dx = $p2['x'] - $p1['x'];
318
+ $dy = $p2['y'] - $p1['y'];
319
+ $dt = $p2['t'] - $p1['t'];
320
+ $distance = sqrt($dx * $dx + $dy * $dy);
321
+
322
+ $totalPressure += (float)($p2['p'] ?? 0.0);
323
+ $totalRadius += (float)($p2['r'] ?? 0.0);
324
+ if (($p2['num'] ?? 1) > $maxTouches) {
325
+ $maxTouches = (int)$p2['num'];
326
+ }
327
+
328
+ if ($dt > 0) {
329
+ $speed = $distance / $dt;
330
+ $segments[] = ['distance' => $distance, 'dt' => $dt, 'speed' => $speed];
331
+ $totalDistance += $distance;
332
+ }
333
+ if ($dt > 100 && $distance < 5) {
334
+ $pauses++;
335
+ }
336
+ }
337
+
338
+ $totalPressure += (float)($history[0]['p'] ?? 0.0);
339
+ $totalRadius += (float)($history[0]['r'] ?? 0.0);
340
+
341
+ $avgPressure = $totalPressure / count($history);
342
+ $avgRadius = $totalRadius / count($history);
343
+
344
+ $sqDiffPressureSum = 0.0;
345
+ $sqDiffRadiusSum = 0.0;
346
+ foreach ($history as $pt) {
347
+ $sqDiffPressureSum += pow((float)($pt['p'] ?? 0.0) - $avgPressure, 2);
348
+ $sqDiffRadiusSum += pow((float)($pt['r'] ?? 0.0) - $avgRadius, 2);
349
+ }
350
+ $pressureVariance = $sqDiffPressureSum / count($history);
351
+ $radiusVariance = $sqDiffRadiusSum / count($history);
352
+
353
+ if (count($segments) < 2) {
354
+ return [
355
+ 'avgSpeed' => 0.0, 'avgAcceleration' => 0.0, 'straightness' => 1.0, 'pauses' => $pauses, 'segments' => [],
356
+ 'avgPressure' => $avgPressure, 'avgRadius' => $avgRadius, 'pressureVariance' => $pressureVariance, 'radiusVariance' => $radiusVariance, 'maxTouches' => $maxTouches
357
+ ];
358
+ }
292
359
 
360
+ $totalTime = $history[count($history) - 1]['t'] - $history[0]['t'];
361
+ $avgSpeed = $totalTime > 0 ? array_sum(array_column($segments, 'speed')) / count($segments) : 0.0;
362
+
363
+ $totalAbsAcceleration = 0.0;
364
+ for ($i = 1; $i < count($segments); $i++) {
365
+ $s1 = $segments[$i - 1];
366
+ $s2 = $segments[$i];
367
+ if ($s2['dt'] > 0) {
368
+ $acceleration = ($s2['speed'] - $s1['speed']) / $s2['dt'];
369
+ $totalAbsAcceleration += abs($acceleration);
370
+ }
371
+ }
372
+ $avgAcceleration = $totalAbsAcceleration / (count($segments) - 1);
373
+
374
+ $startPoint = $history[0];
375
+ $endPoint = $history[count($history) - 1];
376
+ $straightDistance = sqrt(pow($endPoint['x'] - $startPoint['x'], 2) + pow($endPoint['y'] - $startPoint['y'], 2));
377
+ $straightness = $totalDistance > 0 ? $straightDistance / $totalDistance : 1.0;
378
+
379
+ return [
380
+ 'avgSpeed' => $avgSpeed,
381
+ 'avgAcceleration' => $avgAcceleration,
382
+ 'straightness' => $straightness,
383
+ 'pauses' => $pauses,
384
+ 'segments' => array_column($segments, 'distance'),
385
+ 'avgPressure' => $avgPressure,
386
+ 'avgRadius' => $avgRadius,
387
+ 'pressureVariance' => $pressureVariance,
388
+ 'radiusVariance' => $radiusVariance,
389
+ 'maxTouches' => $maxTouches
390
+ ];
391
+ }
293
392
 
294
393
  /**
295
394
  * Calcule un score basé sur les métriques comportementales envoyées par le client.
@@ -314,6 +413,7 @@ class RequestUtils
314
413
  $score = 0.0;
315
414
 
316
415
  $mouseAnalysis = self::analyzeMouseMovements($metrics['mouseMovementsHistory'] ?? null);
416
+ $touch = self::analyzeTouchMovements($metrics['touchMovementsHistory'] ?? null);
317
417
 
318
418
  if (isset($metrics['historyLength'])) {
319
419
  if ($metrics['historyLength'] === 1) $score += 15;
@@ -321,7 +421,7 @@ class RequestUtils
321
421
  elseif ($metrics['historyLength'] >= 2) $score -= 10;
322
422
  } else {
323
423
  // Pénalité pour absence totale d'interaction si l'historique n'est pas dispo
324
- if ($mouseAnalysis['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
424
+ if ($mouseAnalysis['avgSpeed'] == 0 && $touch['avgSpeed'] == 0 && ($metrics['keystrokeLatency'] ?? 0) == 0) {
325
425
  $score += 40;
326
426
  }
327
427
  }
@@ -343,6 +443,30 @@ class RequestUtils
343
443
  $score += 35;
344
444
  }
345
445
  }
446
+ // Analyse comportementale des événements tactiles (Touch Move)
447
+ $touchHistory = $metrics['touchMovementsHistory'] ?? null;
448
+ if (!empty($touchHistory)) {
449
+ if ($touch['avgSpeed'] > 0) {
450
+ if ($touch['avgSpeed'] > 5) $score += 30;
451
+ if ($touch['avgAcceleration'] > 0.8) $score += 20;
452
+ if ($touch['straightness'] > 0.98) $score += 35;
453
+ if ($touch['pauses'] === 0 && count($touch['segments']) > 25) $score += 15;
454
+
455
+ // Détection de l'émulation (pression et rayon de contact constants)
456
+ if ($touch['avgPressure'] > 0 && $touch['pressureVariance'] == 0) {
457
+ $score += 30;
458
+ }
459
+ if ($touch['avgRadius'] > 0 && $touch['radiusVariance'] == 0) {
460
+ $score += 30;
461
+ }
462
+ }
463
+ if (count($touch['segments']) > 10) {
464
+ $benfordDev = Optimization::benfordTest($touch['segments']);
465
+ if ($benfordDev > 0.18) {
466
+ $score += 35;
467
+ }
468
+ }
469
+ }
346
470
 
347
471
  return ['behaviorScore' => min(100.0, $score)];
348
472
  }
@@ -444,6 +568,50 @@ class RequestUtils
444
568
  return ['clientHintsInconsistencyScore' => 0.0];
445
569
  }
446
570
 
571
+ $fullVersionList = $context->getHeader('sec-ch-ua-full-version-list');
572
+ if (!empty($fullVersionList)) {
573
+ $chFullVersion = null;
574
+ $chFullBrowser = null;
575
+ if (preg_match_all('/"([^"]+)";v="([^"]+)"/', $fullVersionList, $matches, PREG_SET_ORDER)) {
576
+ foreach ($matches as $match) {
577
+ $brand = $match[1];
578
+ $version = $match[2];
579
+ if ($brand === 'Google Chrome' || $brand === 'Chromium' || $brand === 'Microsoft Edge') {
580
+ $chFullVersion = $version;
581
+ $chFullBrowser = $brand === 'Microsoft Edge' ? 'Edge' : 'Chrome';
582
+ if ($brand === 'Google Chrome' || $brand === 'Microsoft Edge') {
583
+ break;
584
+ }
585
+ }
586
+ }
587
+ }
588
+ if ($chFullVersion && $chFullBrowser) {
589
+ if (preg_match('/(Chrome|Edg)\/([\d\.]+)/', $ua, $uaFullMatches)) {
590
+ $uaBrowserMapped = $uaFullMatches[1] === 'Edg' ? 'Edge' : 'Chrome';
591
+ $uaFullVersion = $uaFullMatches[2];
592
+ if ($uaBrowserMapped === $chFullBrowser && $uaFullVersion !== $chFullVersion) {
593
+ $parts1 = array_map('intval', explode('.', $uaFullVersion));
594
+ $parts2 = array_map('intval', explode('.', $chFullVersion));
595
+ $diffIndex = -1;
596
+ $maxLen = max(count($parts1), count($parts2));
597
+ for ($i = 0; $i < $maxLen; $i++) {
598
+ $p1 = $parts1[$i] ?? 0;
599
+ $p2 = $parts2[$i] ?? 0;
600
+ if ($p1 !== $p2) {
601
+ $diffIndex = $i;
602
+ break;
603
+ }
604
+ }
605
+ $baseScores = [95.0, 90.0, 85.0, 80.0];
606
+ $baseScore = $baseScores[$diffIndex] ?? 80.0;
607
+ $delta = abs(($parts1[$diffIndex] ?? 0) - ($parts2[$diffIndex] ?? 0));
608
+ $finalFullScore = min(100.0, $baseScore + min(5.0, $delta * 5.0));
609
+ return ['clientHintsInconsistencyScore' => $finalFullScore];
610
+ }
611
+ }
612
+ }
613
+ }
614
+
447
615
  // 1. Extraire la version du navigateur depuis le User-Agent
448
616
  $uaVersion = null;
449
617
  if (preg_match('/(Chrome|Firefox|Edg|Safari)\/([\d\.]+)/', $ua, $uaMatches)) {
@@ -479,13 +647,19 @@ class RequestUtils
479
647
  return ['clientHintsInconsistencyScore' => 90.0];
480
648
  }
481
649
 
482
- if ($versionDifference > 5) { // Un écart de plus de 5 versions majeures est très suspect
483
- return ['clientHintsInconsistencyScore' => 80.0];
484
- } elseif ($versionDifference > 1) { // Un petit écart est légèrement suspect
485
- return ['clientHintsInconsistencyScore' => 40.0];
650
+ $clientHintsInconsistencyScore = 0.0;
651
+ if ($versionDifference > 0) {
652
+ if ($versionDifference <= 2) {
653
+ $clientHintsInconsistencyScore = $versionDifference * 20.0;
654
+ } elseif ($versionDifference <= 7) {
655
+ $clientHintsInconsistencyScore = 40.0 + ($versionDifference - 2) * 8.0;
656
+ } else {
657
+ $clientHintsInconsistencyScore = min(100.0, 80.0 + ($versionDifference - 7) * 3.33);
658
+ }
659
+ $clientHintsInconsistencyScore = round($clientHintsInconsistencyScore, 1);
486
660
  }
487
661
 
488
- return ['clientHintsInconsistencyScore' => 0.0];
662
+ return ['clientHintsInconsistencyScore' => $clientHintsInconsistencyScore];
489
663
  }
490
664
  /**
491
665
  * Calcule les indicateurs comportementaux liés à l'historique de l'appareil.
@@ -553,7 +727,7 @@ class RequestUtils
553
727
  * @param string $fpString La chaîne d'empreinte complète.
554
728
  * @return string La sous-chaîne de l'empreinte contenant uniquement les parties stables.
555
729
  */
556
- private static function extractStablePart(string $fpString): string
730
+ public static function extractStablePart(string $fpString): string
557
731
  {
558
732
  $stableKeys = ['ua', 'ja3', 'ja4', 'h2', 'tcp'];
559
733
  $parts = explode('|', $fpString);
@@ -584,6 +758,9 @@ class RequestUtils
584
758
  $patternWeight = $patternConfig['patternWeight'] ?? 80;
585
759
  $decayFactor = $patternConfig['decayFactor'] ?? 0.95;
586
760
  $inactivityReset = $patternConfig['inactivityReset'] ?? 180000;
761
+ $regularityRatio = $patternConfig['regularityRatio'] ?? 0.4;
762
+ $benfordRatio = $patternConfig['benfordRatio'] ?? 0.3;
763
+ $enumerationRatio = $patternConfig['enumerationRatio'] ?? 0.3;
587
764
 
588
765
  $now = time() * 1000;
589
766
  $history = $deviceData['requestHistory'] ?? [];
@@ -606,7 +783,8 @@ class RequestUtils
606
783
  }
607
784
  $deviceData['requestHistory'] = $history;
608
785
 
609
- $instantScore = 0;
786
+ $regularityScore = 0.0;
787
+ $benfordScore = 0.0;
610
788
  $timings = $deviceData['timingHistory'];
611
789
 
612
790
  // Analyse statistique si nous avons assez de données
@@ -621,18 +799,16 @@ class RequestUtils
621
799
  $stdDev = sqrt($variance);
622
800
  $benfordDeviation = Optimization::benfordTest($timings);
623
801
 
624
- // Détection de régularité (bots de type "cron")
625
802
  if ($stdDev < $regularityThreshold) {
626
- $instantScore = $patternWeight;
803
+ $regularityScore = 1.0 - ($stdDev / $regularityThreshold);
627
804
  }
628
- // Détection de distribution non-naturelle (bots "faussement aléatoires")
629
- elseif ($benfordDeviation > $benfordThreshold) {
630
- $instantScore = $patternWeight;
805
+ if ($benfordDeviation > $benfordThreshold) {
806
+ $benfordScore = min(1.0, ($benfordDeviation - $benfordThreshold) / (0.5 - $benfordThreshold));
631
807
  }
632
808
  }
633
809
 
634
- // Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
635
- $enumerationScore = 0;
810
+ // Path enumeration progressif
811
+ $enumerationScore = 0.0;
636
812
  if (count($history) >= 3) {
637
813
  $templates = array_map(function($h) {
638
814
  return preg_replace('/\d+/', '{num}', $h['path']);
@@ -646,10 +822,15 @@ class RequestUtils
646
822
  $maxTemplateRepetition = !empty($templateCounts) ? max($templateCounts) : 0;
647
823
 
648
824
  if ($maxTemplateRepetition >= 3 && count($uniquePaths) === count($history)) {
649
- $enumerationScore = $patternWeight * 0.8;
825
+ $enumerationScore = min(1.0, ($maxTemplateRepetition - 2) / 5.0);
650
826
  }
651
827
  }
652
828
 
829
+ $weightedScore = ($regularityScore * $regularityRatio) +
830
+ ($benfordScore * $benfordRatio) +
831
+ ($enumerationScore * $enumerationRatio);
832
+ $instantScore = $weightedScore * $patternWeight;
833
+
653
834
  // Logique de décroissance et de score final
654
835
  $newPatternScore = $deviceData['lastPatternScore'] ?? 0;
655
836
 
@@ -660,7 +841,7 @@ class RequestUtils
660
841
  }
661
842
  $newPatternScore = max(0, $newPatternScore);
662
843
 
663
- $deviceData['lastPatternScore'] = $newPatternScore + $instantScore + $enumerationScore;
844
+ $deviceData['lastPatternScore'] = max((float)$instantScore, (float)$newPatternScore);
664
845
 
665
846
  return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
666
847
  }
@@ -914,14 +1095,18 @@ class RequestUtils
914
1095
  $subnetData['highScoreDevices'] = [];
915
1096
  }
916
1097
 
917
- $currentDeviceContributions = $subnetData['highScoreDevices'][$deviceId] ?? 0;
1098
+ // Utilisation de la partie stable du fingerprint matériel plutôt que l'ID de cookie volatil
1099
+ $currentDeviceHash = self::getCompositeDeviceHash($context);
1100
+ $stableFpId = FingerprintBuilder::cyrb53(self::extractStablePart($currentDeviceHash));
1101
+
1102
+ $currentDeviceContributions = $subnetData['highScoreDevices'][$stableFpId] ?? 0;
918
1103
  if ($currentDeviceContributions < 5 && $finalScore < 95) {
919
- $subnetData['highScoreDevices'][$deviceId] = $currentDeviceContributions + 1;
1104
+ $subnetData['highScoreDevices'][$stableFpId] = $currentDeviceContributions + 1;
920
1105
  $subnetData['highScoreCount']++;
921
1106
  }
922
1107
 
923
- if (!in_array($deviceId, $subnetData['deviceIds'])) {
924
- $subnetData['deviceIds'][] = $deviceId;
1108
+ if (!in_array($stableFpId, $subnetData['deviceIds'], true)) {
1109
+ $subnetData['deviceIds'][] = $stableFpId;
925
1110
  }
926
1111
  $subnetData['lastActivity'] = time();
927
1112
 
@@ -986,6 +1171,56 @@ class RequestUtils
986
1171
  return ['subnetScore' => min(100.0, $score)];
987
1172
  }
988
1173
 
1174
+ /**
1175
+ * Calcule le score d'anomalie de similarité réseau (Botnet Clustering).
1176
+ * @param RequestContext $context
1177
+ * @param string $stableFpHash
1178
+ * @return array{'botnetClusterScore': float}
1179
+ */
1180
+ public static function getBotnetClusterScore(RequestContext $context, string $stableFpHash): array
1181
+ {
1182
+ if (empty($stableFpHash)) {
1183
+ return ['botnetClusterScore' => 0.0];
1184
+ }
1185
+
1186
+ $store = StoreManager::getStore();
1187
+ $key = "botnet-cluster:{$stableFpHash}";
1188
+ $now = time();
1189
+ $tenMinutesAgo = $now - 600;
1190
+
1191
+ $clusterData = $store->get($key) ?? [];
1192
+ if (!is_array($clusterData)) {
1193
+ $clusterData = [];
1194
+ }
1195
+
1196
+ $clusterData = array_filter($clusterData, function ($entry) use ($tenMinutesAgo) {
1197
+ return isset($entry['timestamp']) && $entry['timestamp'] > $tenMinutesAgo;
1198
+ });
1199
+ $clusterData = array_values($clusterData);
1200
+
1201
+ $found = false;
1202
+ foreach ($clusterData as &$entry) {
1203
+ if (isset($entry['ip']) && $entry['ip'] === $context->clientIp) {
1204
+ $entry['timestamp'] = $now;
1205
+ $found = true;
1206
+ break;
1207
+ }
1208
+ }
1209
+ unset($entry);
1210
+
1211
+ if (!$found) {
1212
+ $clusterData[] = ['ip' => $context->clientIp, 'timestamp' => $now];
1213
+ }
1214
+
1215
+ $store->set($key, $clusterData, 600);
1216
+ $uniqueIpsCount = count($clusterData);
1217
+ $botnetClusterScore = 0.0;
1218
+ if ($uniqueIpsCount >= 2) {
1219
+ $botnetClusterScore = min(100.0, round(100.0 * (1.0 - exp(-0.35 * ($uniqueIpsCount - 1))), 1));
1220
+ }
1221
+ return ['botnetClusterScore' => $botnetClusterScore];
1222
+ }
1223
+
989
1224
  /**
990
1225
  * Calcule le score de réputation d'une IP en appliquant la décroissance temporelle.
991
1226
  */
@@ -1130,6 +1365,183 @@ class RequestUtils
1130
1365
  return hash_equals($expectedSignature, $storedSignature);
1131
1366
  }
1132
1367
 
1368
+ /**
1369
+ * Parse une trame TCP SYN brute (IPv4 ou IPv6).
1370
+ */
1371
+ public static function parseTcpSyn(?string $binary): ?array
1372
+ {
1373
+ if (!$binary || strlen($binary) < 40) return null;
1374
+ $ttl = 64;
1375
+ $tcpOffset = 20;
1376
+ $version = ord($binary[0]) >> 4;
1377
+
1378
+ if ($version === 4) {
1379
+ $ttl = ord($binary[8]);
1380
+ $ihl = ord($binary[0]) & 0x0f;
1381
+ $tcpOffset = $ihl * 4;
1382
+ } elseif ($version === 6) {
1383
+ $ttl = ord($binary[7]); // Hop Limit
1384
+ $tcpOffset = 40;
1385
+ } else {
1386
+ $tcpOffset = 0;
1387
+ $ttl = 64;
1388
+ }
1389
+
1390
+ if (strlen($binary) < $tcpOffset + 20) return null;
1391
+
1392
+ $windowSize = (ord($binary[$tcpOffset + 14]) << 8) | ord($binary[$tcpOffset + 15]);
1393
+ $dataOffset = (ord($binary[$tcpOffset + 12]) >> 4) * 4;
1394
+ $optionsEnd = $tcpOffset + $dataOffset;
1395
+
1396
+ $mss = null;
1397
+ $ws = null;
1398
+ $sack = false;
1399
+
1400
+ $i = $tcpOffset + 20;
1401
+ while ($i < $optionsEnd && $i < strlen($binary)) {
1402
+ $optType = ord($binary[$i]);
1403
+ if ($optType === 0) break;
1404
+ if ($optType === 1) {
1405
+ $i++;
1406
+ continue;
1407
+ }
1408
+ if ($i + 1 >= strlen($binary)) break;
1409
+ $optLen = ord($binary[$i + 1]);
1410
+ if ($optLen < 2 || $i + $optLen > strlen($binary)) break;
1411
+
1412
+ if ($optType === 2 && $optLen === 4) {
1413
+ $mss = (ord($binary[$i + 2]) << 8) | ord($binary[$i + 3]);
1414
+ } elseif ($optType === 3 && $optLen === 3) {
1415
+ $ws = ord($binary[$i + 2]);
1416
+ } elseif ($optType === 4 && $optLen === 2) {
1417
+ $sack = true;
1418
+ }
1419
+ $i += $optLen;
1420
+ }
1421
+
1422
+ return ['ttl' => $ttl, 'windowSize' => $windowSize, 'mss' => $mss, 'ws' => $ws, 'sack' => $sack];
1423
+ }
1424
+
1425
+ /**
1426
+ * Classifie l'OS à partir du fingerprint de la pile TCP/IP.
1427
+ */
1428
+ public static function classifyTcpOs(?array $fingerprint): string
1429
+ {
1430
+ if (!$fingerprint) return 'unknown';
1431
+ $ttl = $fingerprint['ttl'] ?? 64;
1432
+ $windowSize = $fingerprint['windowSize'] ?? 0;
1433
+ $ws = $fingerprint['ws'] ?? null;
1434
+
1435
+ if ($ttl > 64 && $ttl <= 128) {
1436
+ return 'Windows';
1437
+ }
1438
+ if ($ttl > 32 && $ttl <= 64) {
1439
+ if ($windowSize === 29200 || $windowSize === 14600 || $windowSize === 5840) {
1440
+ return 'Linux';
1441
+ }
1442
+ return 'Linux';
1443
+ }
1444
+ if ($ttl <= 64) {
1445
+ if ($windowSize === 65535 && ($ws === 6 || $ws === 8 || $ws === 5)) {
1446
+ return 'macOS/iOS';
1447
+ }
1448
+ }
1449
+ if ($ttl > 64) return 'Windows';
1450
+ if ($ttl > 0) return 'Linux';
1451
+ return 'unknown';
1452
+ }
1453
+
1454
+ /**
1455
+ * Détecte les anomalies de pile réseau par rapport au User-Agent.
1456
+ */
1457
+ public static function getTcpAnomalyScore(RequestContext $context): array
1458
+ {
1459
+ $fp = null;
1460
+ $rawTcpBinary = $context->getHeader('x-raw-tcp-binary') ?? $context->headers['x-raw-tcp-binary'] ?? null;
1461
+ if ($rawTcpBinary) {
1462
+ $binary = @hex2bin($rawTcpBinary) ?: $rawTcpBinary;
1463
+ $fp = self::parseTcpSyn($binary);
1464
+ }
1465
+
1466
+ if (!$fp) {
1467
+ $tcpHeader = $context->getHeader('x-tcp-fingerprint') ?? $context->tcpFingerprint ?? null;
1468
+ if ($tcpHeader && is_string($tcpHeader)) {
1469
+ $parts = explode(':', $tcpHeader);
1470
+ if (count($parts) >= 2) {
1471
+ $fp = [
1472
+ 'ttl' => (int)$parts[0],
1473
+ 'windowSize' => (int)$parts[1],
1474
+ 'mss' => isset($parts[2]) ? (int)$parts[2] : null,
1475
+ 'ws' => isset($parts[3]) ? (int)$parts[3] : null,
1476
+ 'sack' => isset($parts[4]) && ($parts[4] === '1' || $parts[4] === 'true')
1477
+ ];
1478
+ }
1479
+ }
1480
+ }
1481
+
1482
+ if (!$fp) {
1483
+ return ['tcpAnomalyScore' => 0.0];
1484
+ }
1485
+
1486
+ $tcpOs = self::classifyTcpOs($fp);
1487
+ $ua = $context->getHeader('user-agent') ?? '';
1488
+ $uaParts = self::parseUserAgent($ua);
1489
+ $uaOs = $uaParts['os'] ?? null;
1490
+
1491
+ if (!$uaOs || $tcpOs === 'unknown') {
1492
+ return ['tcpAnomalyScore' => 0.0];
1493
+ }
1494
+
1495
+ $mappedOs = null;
1496
+ if (str_starts_with($uaOs, 'Windows')) {
1497
+ $mappedOs = 'Windows';
1498
+ } elseif (str_starts_with($uaOs, 'Mac') || str_starts_with($uaOs, 'macOS')) {
1499
+ $mappedOs = 'macOS';
1500
+ } elseif (str_starts_with($uaOs, 'iOS')) {
1501
+ $mappedOs = 'iOS';
1502
+ } elseif (str_starts_with($uaOs, 'Linux')) {
1503
+ $mappedOs = 'Linux';
1504
+ }
1505
+
1506
+ if ($mappedOs === null) {
1507
+ return ['tcpAnomalyScore' => 0.0];
1508
+ }
1509
+
1510
+ $osExpectedTcp = [
1511
+ 'Windows' => ['ttl' => 128, 'windowSize' => 64240, 'ws' => 8, 'mss' => 1460, 'sack' => true],
1512
+ 'Linux' => ['ttl' => 64, 'windowSize' => 29200, 'ws' => 7, 'mss' => 1460, 'sack' => true],
1513
+ 'macOS' => ['ttl' => 64, 'windowSize' => 65535, 'ws' => 6, 'mss' => 1460, 'sack' => true],
1514
+ 'iOS' => ['ttl' => 64, 'windowSize' => 65535, 'ws' => 6, 'mss' => 1460, 'sack' => true]
1515
+ ];
1516
+
1517
+ $expected = $osExpectedTcp[$mappedOs];
1518
+ $ttlDiff = abs($fp['ttl'] - $expected['ttl']) / $expected['ttl'];
1519
+ $winDiff = abs($fp['windowSize'] - $expected['windowSize']) / $expected['windowSize'];
1520
+ $wsDiff = $expected['ws'] !== null && ($fp['ws'] ?? null) !== null ? abs($fp['ws'] - $expected['ws']) / $expected['ws'] : 0.0;
1521
+ $mssDiff = $expected['mss'] !== null && ($fp['mss'] ?? null) !== null ? abs($fp['mss'] - $expected['mss']) / $expected['mss'] : 0.0;
1522
+ $sackDiff = ($fp['sack'] ?? true) === ($expected['sack'] ?? true) ? 0.0 : 1.0;
1523
+
1524
+ $deviation = (
1525
+ min(1.0, $ttlDiff) * 0.50 +
1526
+ min(1.0, $winDiff) * 0.25 +
1527
+ min(1.0, $wsDiff) * 0.15 +
1528
+ min(1.0, $mssDiff) * 0.05 +
1529
+ $sackDiff * 0.05
1530
+ );
1531
+
1532
+ $tcpAnomalyScore = 0.0;
1533
+ if ($tcpOs !== $mappedOs && $tcpOs !== 'unknown') {
1534
+ $baseAnomaly = $mappedOs === 'Windows' ? 80.0 :
1535
+ (($mappedOs === 'macOS' || $mappedOs === 'iOS') ? 85.0 : 75.0);
1536
+ $tcpAnomalyScore = $baseAnomaly + ($deviation - 0.4) * 10.0;
1537
+ } else {
1538
+ $tcpAnomalyScore = $deviation * 40.0;
1539
+ }
1540
+
1541
+ $tcpAnomalyScore = max(0.0, min(100.0, round($tcpAnomalyScore, 1)));
1542
+ return ['tcpAnomalyScore' => $tcpAnomalyScore];
1543
+ }
1544
+
1133
1545
  /**
1134
1546
  * Vérifie si un ticket de clearance (PoW) est valide, en supportant la tolérance au roaming.
1135
1547
  *
@@ -1146,6 +1558,47 @@ class RequestUtils
1146
1558
  return false;
1147
1559
  }
1148
1560
 
1561
+ if (str_contains($ticket, '.')) {
1562
+ $parts = explode('.', $ticket);
1563
+ if (count($parts) === 3) {
1564
+ $base64UrlDecode = function ($input) {
1565
+ return base64_decode(strtr($input, '-_', '+/'));
1566
+ };
1567
+ $iv = $base64UrlDecode($parts[0]);
1568
+ $encrypted = $base64UrlDecode($parts[1]);
1569
+ $signature = $base64UrlDecode($parts[2]);
1570
+ if ($iv && $encrypted && $signature && strlen($iv) === 16) {
1571
+ $key = hash('sha256', $secret ?: "fallback-dev-secret-32-chars-minimum", true);
1572
+ $expectedSignature = hash_hmac('sha256', $iv . $encrypted, $key, true);
1573
+ if (hash_equals($expectedSignature, $signature)) {
1574
+ $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
1575
+ if ($decrypted !== false) {
1576
+ $ticketData = json_decode($decrypted, true);
1577
+ if ($ticketData) {
1578
+ $expiry = $ticketData['expiry'] ?? null;
1579
+ $originalIp = $ticketData['originalIp'] ?? null;
1580
+ $storedDeviceId = $ticketData['deviceId'] ?? '';
1581
+ $storedDeviceHash = $ticketData['deviceHash'] ?? '';
1582
+ if (!$expiry || (time() * 1000) > (int)$expiry) {
1583
+ return false;
1584
+ }
1585
+ if ($ip === $originalIp) {
1586
+ return true;
1587
+ }
1588
+ $currentSubnet = self::getIpSubnet($ip);
1589
+ $originalSubnet = self::getIpSubnet($originalIp);
1590
+ if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
1591
+ return true;
1592
+ }
1593
+ return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
1594
+ }
1595
+ }
1596
+ }
1597
+ }
1598
+ }
1599
+ return false;
1600
+ }
1601
+
1149
1602
  if (str_contains($ticket, '|')) {
1150
1603
  $parts = explode('|', $ticket);
1151
1604
  if (count($parts) < 3) return false;