@anonympins/fingerprint 0.7.0 → 0.7.2

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 (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/composer.json +8 -1
  4. package/package.json +1 -1
  5. package/public/fingerprint-wordpress.zip +0 -0
  6. package/src/js/build-client.js +8 -10
  7. package/src/js/fingerprint.builder.js +37 -38
  8. package/src/js/fingerprint.client.js +1531 -1463
  9. package/src/js/fingerprint.client.obfuscated.js +1 -0
  10. package/src/js/fingerprint.js +190 -16
  11. package/src/js/fingerprint.utils.js +213 -213
  12. package/src/js/library.js +340 -376
  13. package/src/js/mongodb-store.js +1 -1
  14. package/src/js/optimization.worker.js +27 -27
  15. package/src/js/pow.solver.inline.js +154 -56
  16. package/src/js/pow.solver.js +180 -92
  17. package/src/js/pow.worker.js +48 -48
  18. package/src/js/redis-store.js +1 -1
  19. package/src/js/tests/fingerprint.client.init.test.js +146 -143
  20. package/src/js/tests/fingerprint.test.js +124 -10
  21. package/src/js/tests/ip-reputation.test.js +1 -0
  22. package/src/js/upow-model-task.js +14 -15
  23. package/src/php/AutoTuner.php +34 -34
  24. package/src/php/Challenge/ChallengeUtils.php +57 -11
  25. package/src/php/Config/SecurityProfiles.php +20 -0
  26. package/src/php/DirectFingerprint.php +190 -145
  27. package/src/php/FingerprintBuilder.php +29 -30
  28. package/src/php/FingerprintClient.php +147 -148
  29. package/src/php/FingerprintEngine.php +44 -17
  30. package/src/php/Ja3AnomalyDetector.php +33 -32
  31. package/src/php/Optimization/FunctionRegistry.php +7 -7
  32. package/src/php/Optimization/Optimization.php +24 -24
  33. package/src/php/Optimization/OptimizationOperators.php +26 -25
  34. package/src/php/Optimization/ProblemInitializers.php +52 -52
  35. package/src/php/ProblemManager.php +31 -34
  36. package/src/php/RequestContext.php +8 -1
  37. package/src/php/Store/IStore.php +7 -8
  38. package/src/php/Store/InMemoryStore.php +66 -66
  39. package/src/php/Store/MongoDbStore.php +5 -5
  40. package/src/php/Store/RedisStore.php +5 -5
  41. package/src/php/Store/StoreManager.php +35 -35
  42. package/src/php/Tests/ChallengeUtilsTest.php +453 -453
  43. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  44. package/src/php/Tests/FingerprintEngineTest.php +38 -0
  45. package/src/php/Tests/IpReputationTest.php +10 -0
  46. package/src/php/Tests/MaliciousPatternsTest.php +103 -103
  47. package/src/php/Tests/MetricsTest.php +97 -97
  48. package/src/php/Tests/ProblemManagerTest.php +376 -376
  49. package/src/php/Tests/QuicFingerprintTest.php +53 -53
  50. package/src/php/Tests/RequestUtilsTest.php +471 -471
  51. package/src/php/Tests/TLSClientHelloParserTest.php +177 -177
  52. package/src/php/Tests/config/ed25519_key.json +3 -3
  53. package/src/php/Utils/BlockList.php +99 -99
  54. package/src/php/Utils/Env.php +49 -49
  55. package/src/php/Utils/MaliciousPatterns.php +74 -74
  56. package/src/php/Utils/MetricsManager.php +209 -209
  57. package/src/php/Utils/RequestUtils.php +129 -7
  58. package/src/php/Utils/TLSClientHelloParser.php +369 -369
  59. package/src/php/WordPress/WpDbStore.php +155 -0
  60. package/src/php/WordPress/fingerprint-wordpress.php +554 -0
  61. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  62. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +262 -0
  63. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  64. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +268 -0
  65. package/src/php/WordPress/package.php +218 -0
  66. package/src/php/bin/auto-tune.php +118 -118
@@ -1,149 +1,148 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint;
6
- use Anonympins\Fingerprint\Config\SecurityProfiles;
7
-
8
- /**
9
- * FingerprintClient - Wrapper PHP pour la bibliothèque de fingerprinting côté client.
10
- *
11
- * Cette classe facilite l'intégration de la bibliothèque JavaScript `fingerprint.client.js`
12
- * dans une application PHP. Elle gère l'injection sécurisée du script et la création
13
- * de "honeypots" (pièges à bots) dans les formulaires.
14
- */
15
- class FingerprintClient
16
- {
17
- /**
18
- * @var string Le chemin vers le fichier de la bibliothèque client JavaScript.
19
- */
20
- private string $clientScriptPath;
21
-
22
- /**
23
- * @var array La configuration à passer à la fonction `initializeClient` de la bibliothèque JS.
24
- */
25
- private array $clientConfig;
26
-
27
- /**
28
- * @var string|null Un nonce cryptographique pour la Content Security Policy (CSP).
29
- */
30
- private ?string $nonce;
31
-
32
- /**
33
- * Constructeur de la classe.
34
- *
35
- * @param string $clientScriptPath Le chemin d'accès web au fichier `fingerprint.client.js`.
36
- * @param array $clientConfig La configuration pour la bibliothèque client (souris, frappes, honeypots, etc.).
37
- */
38
- public function __construct(string $clientScriptPath, array $clientConfig = [])
39
- {
40
- $this->clientScriptPath = $clientScriptPath;
41
-
42
- $defaultConfig = [
43
- 'mouse' => true,
44
- 'keystrokes' => true,
45
- 'clicks' => true,
46
- 'honeypots' => [],
47
- 'fetch' => [
48
- 'handleChallenges' => true,
49
- 'probationaryTtl' => 30000, // 30 seconds
50
- ],
51
- 'wasm' => true, // Activer la tentative de chargement du module WASM
52
- 'wasmPath' => '/fp.js' // Chemin vers le script de chargement WASM
53
- ];
54
-
55
- // Utiliser une fusion profonde pour permettre de surcharger des sous-clés
56
- $this->clientConfig = SecurityProfiles::deepMerge($defaultConfig, $clientConfig);
57
-
58
- try {
59
- // Génère un nonce pour CSP si possible, pour une sécurité renforcée.
60
- $this->nonce = bin2hex(random_bytes(16));
61
- } catch (\Exception $e) {
62
- $this->nonce = null;
63
- }
64
- }
65
-
66
- /**
67
- * Génère un champ de formulaire "honeypot" caché.
68
- * Les bots le rempliront, mais il sera invisible pour les humains.
69
- *
70
- * @param string $fieldName Le nom du champ (doit correspondre à la configuration client).
71
- * @return string Le code HTML du champ honeypot.
72
- */
73
- public function generateHoneypotField(string $fieldName): string
74
- {
75
- // Ajoute le champ à la configuration pour que le script client le surveille.
76
- if (!in_array($fieldName, $this->clientConfig['honeypots'])) {
77
- $this->clientConfig['honeypots'][] = $fieldName;
78
- }
79
-
80
- // Styles CSS polymorphiques pour cacher le champ de manière robuste.
81
- $styleOptions = [
82
- 'position:absolute; left:-9999px; top:-9999px; transform:scale(0); opacity:0; pointer-events:none;',
83
- 'position:fixed; left:-8888px; top:-8888px; width:0; height:0; overflow:hidden; opacity:0; pointer-events:none;',
84
- 'display:none; visibility:hidden; pointer-events:none;'
85
- ];
86
- $styles = $styleOptions[array_rand($styleOptions)];
87
-
88
- $containerTags = ['div', 'span', 'p', 'section'];
89
- $tag = $containerTags[array_rand($containerTags)];
90
-
91
- $nestingType = rand(0, 1);
92
- if ($nestingType === 1) {
93
- return '<' . $tag . ' style="' . $styles . '" aria-hidden="true">'
94
- . '<label for="' . htmlspecialchars($fieldName) . '">' . htmlspecialchars($fieldName)
95
- . '<input type="text" id="' . htmlspecialchars($fieldName) . '" name="' . htmlspecialchars($fieldName) . '" tabindex="-1" autocomplete="off">'
96
- . '</label>'
97
- . '</' . $tag . '>';
98
- }
99
-
100
- return '<' . $tag . ' style="' . $styles . '" aria-hidden="true">'
101
- . '<label for="' . htmlspecialchars($fieldName) . '">' . htmlspecialchars($fieldName) . '</label>'
102
- . '<input type="text" id="' . htmlspecialchars($fieldName) . '" name="' . htmlspecialchars($fieldName) . '" tabindex="-1" autocomplete="off">'
103
- . '</' . $tag . '>';
104
- }
105
-
106
- /**
107
- * Génère le bloc de script complet à inclure dans une page HTML.
108
- *
109
- * @return string Le code HTML des balises <script>.
110
- */
111
- public function getScriptTag(): string
112
- {
113
- $configJson = json_encode($this->clientConfig);
114
- $nonceAttr = $this->nonce ? ' nonce="' . $this->nonce . '"' : '';
115
-
116
- // Le script d'initialisation qui sera inclus dans la page.
117
- $initScript = <<<JS
118
- document.addEventListener('DOMContentLoaded', function() {
119
- const config = {$configJson};
120
- if (window.ClientLibrary) {
121
- if (config.wasmPath) {
122
- const wasmScript = document.createElement('script');
123
- wasmScript.src = config.wasmPath;
124
- wasmScript.async = true;
125
- wasmScript.nonce = '{$this->nonce}';
126
- document.head.appendChild(wasmScript);
127
- }
128
-
129
- window.ClientLibrary.initializeClient(config);
130
- } else {
131
- console.error('Fingerprint client library not loaded.');
132
- }
133
- });
134
- JS;
135
-
136
- // On combine le chargement de la bibliothèque et le script d'initialisation.
137
- return '<script src="' . htmlspecialchars($this->clientScriptPath) . '"' . $nonceAttr . '></script>'
138
- . '<script' . $nonceAttr . '>' . $initScript . '</script>';
139
- }
140
-
141
- /**
142
- * Retourne le nonce généré pour pouvoir l'utiliser dans les en-têtes CSP.
143
- * @return string|null
144
- */
145
- public function getNonce(): ?string
146
- {
147
- return $this->nonce;
148
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
7
+
8
+ /**
9
+ * FingerprintClient - PHP wrapper for the client-side fingerprinting library.
10
+ *
11
+ * Handles script injection and generation of polymorphic honeypot fields
12
+ * inside HTML forms.
13
+ */
14
+ class FingerprintClient
15
+ {
16
+ /**
17
+ * @var string Path to client script asset.
18
+ */
19
+ private string $clientScriptPath;
20
+
21
+ /**
22
+ * @var array Configuration payload passed to `initializeClient`.
23
+ */
24
+ private array $clientConfig;
25
+
26
+ /**
27
+ * @var string|null Cryptographic nonce for Content Security Policy (CSP).
28
+ */
29
+ private ?string $nonce;
30
+
31
+ /**
32
+ * Constructor.
33
+ *
34
+ * @param string $clientScriptPath Web-accessible path to `fingerprint.client.js`.
35
+ * @param array $clientConfig Client initialization options.
36
+ */
37
+ public function __construct(string $clientScriptPath, array $clientConfig = [])
38
+ {
39
+ $this->clientScriptPath = $clientScriptPath;
40
+
41
+ $defaultConfig = [
42
+ 'mouse' => true,
43
+ 'keystrokes' => true,
44
+ 'clicks' => true,
45
+ 'honeypots' => [],
46
+ 'fetch' => [
47
+ 'handleChallenges' => true,
48
+ 'probationaryTtl' => 30000, // 30 seconds
49
+ ],
50
+ 'wasm' => true, // Attempt to load WASM module
51
+ 'wasmPath' => '/fp.js' // Path to WASM loader script
52
+ ];
53
+
54
+ // Deep merge configuration overrides
55
+ $this->clientConfig = SecurityProfiles::deepMerge($defaultConfig, $clientConfig);
56
+
57
+ try {
58
+ // Generate CSP nonce when possible
59
+ $this->nonce = bin2hex(random_bytes(16));
60
+ } catch (\Exception $e) {
61
+ $this->nonce = null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Generates a hidden honeypot form field.
67
+ * Traps automated bots while remaining invisible to human users.
68
+ *
69
+ * @param string $fieldName Field name (must match client honeypot registration).
70
+ * @return string Generated HTML markup.
71
+ */
72
+ public function generateHoneypotField(string $fieldName): string
73
+ {
74
+ // Register field in client configuration for monitoring
75
+ if (!in_array($fieldName, $this->clientConfig['honeypots'])) {
76
+ $this->clientConfig['honeypots'][] = $fieldName;
77
+ }
78
+
79
+ // Polymorphic CSS styling to robustly obscure honeypots
80
+ $styleOptions = [
81
+ 'position:absolute; left:-9999px; top:-9999px; transform:scale(0); opacity:0; pointer-events:none;',
82
+ 'position:fixed; left:-8888px; top:-8888px; width:0; height:0; overflow:hidden; opacity:0; pointer-events:none;',
83
+ 'display:none; visibility:hidden; pointer-events:none;'
84
+ ];
85
+ $styles = $styleOptions[array_rand($styleOptions)];
86
+
87
+ $containerTags = ['div', 'span', 'p', 'section'];
88
+ $tag = $containerTags[array_rand($containerTags)];
89
+
90
+ $nestingType = rand(0, 1);
91
+ if ($nestingType === 1) {
92
+ return '<' . $tag . ' style="' . $styles . '" aria-hidden="true">'
93
+ . '<label for="' . htmlspecialchars($fieldName) . '">' . htmlspecialchars($fieldName)
94
+ . '<input type="text" id="' . htmlspecialchars($fieldName) . '" name="' . htmlspecialchars($fieldName) . '" tabindex="-1" autocomplete="off">'
95
+ . '</label>'
96
+ . '</' . $tag . '>';
97
+ }
98
+
99
+ return '<' . $tag . ' style="' . $styles . '" aria-hidden="true">'
100
+ . '<label for="' . htmlspecialchars($fieldName) . '">' . htmlspecialchars($fieldName) . '</label>'
101
+ . '<input type="text" id="' . htmlspecialchars($fieldName) . '" name="' . htmlspecialchars($fieldName) . '" tabindex="-1" autocomplete="off">'
102
+ . '</' . $tag . '>';
103
+ }
104
+
105
+ /**
106
+ * Generates HTML script tags to embed the client library on a page.
107
+ *
108
+ * @return string HTML <script> markup.
109
+ */
110
+ public function getScriptTag(): string
111
+ {
112
+ $configJson = json_encode($this->clientConfig);
113
+ $nonceAttr = $this->nonce ? ' nonce="' . $this->nonce . '"' : '';
114
+
115
+ // Inline initialization script embedded in HTML
116
+ $initScript = <<<JS
117
+ document.addEventListener('DOMContentLoaded', function() {
118
+ const config = {$configJson};
119
+ if (window.ClientLibrary) {
120
+ if (config.wasmPath) {
121
+ const wasmScript = document.createElement('script');
122
+ wasmScript.src = config.wasmPath;
123
+ wasmScript.async = true;
124
+ wasmScript.nonce = '{$this->nonce}';
125
+ document.head.appendChild(wasmScript);
126
+ }
127
+
128
+ window.ClientLibrary.initializeClient(config);
129
+ } else {
130
+ console.error('Fingerprint client library not loaded.');
131
+ }
132
+ });
133
+ JS;
134
+
135
+ // Combine library loader script and inline initialization
136
+ return '<script src="' . htmlspecialchars($this->clientScriptPath) . '"' . $nonceAttr . '></script>'
137
+ . '<script' . $nonceAttr . '>' . $initScript . '</script>';
138
+ }
139
+
140
+ /**
141
+ * Returns the generated CSP nonce.
142
+ * @return string|null
143
+ */
144
+ public function getNonce(): ?string
145
+ {
146
+ return $this->nonce;
147
+ }
149
148
  }
@@ -179,6 +179,12 @@
179
179
  }
180
180
  }
181
181
  }
182
+
183
+ // Si aucune liste blanche n'est explicitement fournie, appliquer la liste par défaut.
184
+ if (!isset($securityConfig['whitelist'])) {
185
+ $securityConfig['whitelist'] = self::default_whitelist();
186
+ }
187
+
182
188
  $this->securityConfig = $securityConfig;
183
189
  $this->verbose = $securityConfig['verbose'] ?? false;
184
190
  $this->allowlist = $this->buildAllowlist();
@@ -229,7 +235,8 @@
229
235
  'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
230
236
  'similarityThreshold', 'summary', 'description',
231
237
  'ed25519_private_key', 'ed25519_public_key',
232
- 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset'
238
+ 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset',
239
+ 'filterWhitelist'
233
240
  ];
234
241
 
235
242
  if (empty($config['weights'])) {
@@ -550,10 +557,8 @@
550
557
 
551
558
  $deviceId = bin2hex(random_bytes(16)); // UUID-like
552
559
 
553
- $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
554
- (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
555
- ($context->getHeader('x-forwarded-proto') === 'https');
556
- $secureOption = $isHttps || $this->isProduction;
560
+ $isHttps = !empty($context->isHttps);
561
+ $secureOption = $isHttps;
557
562
 
558
563
  // Préparer le cookie à envoyer
559
564
  $newCookie = [
@@ -562,7 +567,6 @@
562
567
  'options' => [
563
568
  'httponly' => true,
564
569
  'secure' => $secureOption,
565
- 'partitioned' => $secureOption,
566
570
  'samesite' => 'Strict',
567
571
  'path' => '/',
568
572
  ]
@@ -745,7 +749,7 @@
745
749
  $botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
746
750
 
747
751
  // NOUVEAU: Score de réputation du sous-réseau IP
748
- $subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
752
+ $subnetScore = RequestUtils::getSubnetScore($context, $deviceId, $this->securityConfig);
749
753
 
750
754
  // Score d'anomalie de pile TCP/IP
751
755
  $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
@@ -985,13 +989,36 @@
985
989
  // Le fingerprint est cohérent, on peut valider la solution
986
990
  if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
987
991
  $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
988
- if ($cpuSolution) {
989
- $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
992
+ if ($cpuSolution !== null && $cpuSolution !== '') {
993
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
994
+ $deviceId = (string)($identity['deviceId'] ?? '');
995
+ $currentDeviceHash = (string)($identity['currentDeviceHash'] ?? RequestUtils::getCompositeDeviceHash($context));
996
+
997
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket(
998
+ $context->clientIp,
999
+ 3600000,
1000
+ $powNonce,
1001
+ (string)$cpuSolution,
1002
+ $challengeContext,
1003
+ $deviceId,
1004
+ $currentDeviceHash
1005
+ );
990
1006
  $isValid = $ticket !== null;
991
1007
 
992
1008
  if ($powType === 'cpu_mem') {
993
- $memSolution = $context->query['pow_solution_mem'] ?? null;
994
- $isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
1009
+ if (!empty($challengeContext['isHttp'])) {
1010
+ $isMemValid = true;
1011
+ } else {
1012
+ $memSolution = $context->query['pow_solution_mem'] ?? null;
1013
+ $isMemValid = ($memSolution !== null && $memSolution !== '')
1014
+ ? ChallengeUtils::verifyMemoryPoW(
1015
+ $powNonce,
1016
+ (string)$memSolution,
1017
+ $challengeContext['memDifficulty'] ?? 0,
1018
+ $challengeContext['clientSecret'] ?? ''
1019
+ )
1020
+ : false;
1021
+ }
995
1022
  $isValid = $isValid && $isMemValid;
996
1023
  }
997
1024
  }
@@ -1073,10 +1100,8 @@
1073
1100
  MetricsManager::incrementCounter('challenges_solved_total');
1074
1101
  $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
1075
1102
 
1076
- $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
1077
- (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
1078
- ($context->getHeader('x-forwarded-proto') === 'https');
1079
- $secureOption = $isHttps || $this->isProduction;
1103
+ $isHttps = !empty($context->isHttps);
1104
+ $secureOption = $isHttps;
1080
1105
 
1081
1106
  return [
1082
1107
  'action' => 'redirect',
@@ -1092,7 +1117,6 @@
1092
1117
  'samesite' => 'Strict',
1093
1118
  'expires' => time() + ($ticketTtl / 1000),
1094
1119
  'path' => '/',
1095
- 'partitioned' => $secureOption,
1096
1120
  ]
1097
1121
  ]
1098
1122
  ];
@@ -1399,6 +1423,7 @@
1399
1423
  $tlsSessionId = $context->tlsSessionId ?? '';
1400
1424
  $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
1401
1425
 
1426
+ $isHttps = !empty($context->isHttps);
1402
1427
  $challengeContext = [
1403
1428
  'clientSecret' => $clientSecret,
1404
1429
  'cpuTarget' => $cpuChallengeDetails['target'],
@@ -1407,6 +1432,7 @@
1407
1432
  'memDifficulty' => $memDifficulty,
1408
1433
  'baseBlock' => $baseBlock,
1409
1434
  'originalPath' => $context->path,
1435
+ 'isHttp' => !$isHttps,
1410
1436
  ];
1411
1437
 
1412
1438
  $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
@@ -1441,7 +1467,8 @@
1441
1467
  // Pour les navigateurs, retourner une page HTML
1442
1468
  $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1443
1469
  $cpuChallengeDetails, $memDifficulty, $clientSecret,
1444
- $this->securityConfig, $trapUrls, $originalFingerprint
1470
+ $this->securityConfig, $trapUrls, $originalFingerprint,
1471
+ $context->clientIp, $tlsSessionId, $baseBlock, $isHttps
1445
1472
  );
1446
1473
  $decision['body'] = $pageBody;
1447
1474
  }
@@ -5,17 +5,17 @@ declare(strict_types=1);
5
5
  namespace Anonympins\Fingerprint;
6
6
 
7
7
  /**
8
- * Classe de détection d'anomalies et d'usurpation TLS (JA3) en PHP.
8
+ * TLS (JA3) fingerprint anomaly and spoofing detector in PHP.
9
9
  */
10
10
  class Ja3AnomalyDetector
11
11
  {
12
- // Liste décimale des valeurs GREASE (RFC 8701) utilisées par les moteurs Chromium/Safari récents
12
+ // Decimal GREASE values (RFC 8701) used by Chromium and Safari TLS stacks
13
13
  private const GREASE_VALUES = [
14
14
  2570, 6682, 10794, 14906, 19018, 23130, 27242, 31354,
15
15
  35466, 39578, 43690, 47802, 51914, 55926, 60038, 64150
16
16
  ];
17
17
 
18
- // Base de données locale de signatures JA3 MD5 connues pour la corroboration de base
18
+ // Known JA3 MD5 signatures for baseline cross-layer verification
19
19
  private const TLS_FINGERPRINT_DB = [
20
20
  'e188a442b87f422c5a1e80b05399435b' => ['Chrome'],
21
21
  'd8e35855049321c6042a4325c697858f' => ['Chrome'],
@@ -29,7 +29,7 @@ class Ja3AnomalyDetector
29
29
  'b633f21d532d35967c8753c38536b4d3' => ['Safari'],
30
30
  '4d7a28d5f55b359b69100a311013f03e' => ['Safari', 'Chrome', 'Firefox'],
31
31
  '8dd3d7532873575314df23c447543001' => ['Safari', 'Chrome', 'Firefox'],
32
- // Bibliothèques et scrapers automatisés connus
32
+ // Automated scraper and library fingerprints
33
33
  '47344a349b75c4e82333475553b5f358' => ['Python'],
34
34
  'b29587b8a143c42546133ad7704b3310' => ['Go'],
35
35
  'd435b5223b2884c5a832b842637e245f' => ['Java'],
@@ -37,8 +37,8 @@ class Ja3AnomalyDetector
37
37
  ];
38
38
 
39
39
  /**
40
- * Analyse une chaîne JA3 brute non hachée.
41
- * Format attendu : "TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurveFormats"
40
+ * Parses a raw JA3 string.
41
+ * Expected format: "TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurveFormats"
42
42
  */
43
43
  public static function parseJa3(string $ja3String): ?array
44
44
  {
@@ -61,7 +61,7 @@ class Ja3AnomalyDetector
61
61
  }
62
62
 
63
63
  /**
64
- * Vérifie si un tableau contient au moins une valeur GREASE.
64
+ * Checks if a collection contains any GREASE value.
65
65
  */
66
66
  public static function hasGrease(array $values): bool
67
67
  {
@@ -74,7 +74,7 @@ class Ja3AnomalyDetector
74
74
  }
75
75
 
76
76
  /**
77
- * Parse sommairement le User-Agent pour en extraire la famille de navigateur.
77
+ * Extracts browser family from User-Agent string.
78
78
  */
79
79
  public static function getBrowserFamily(string $userAgent): ?string
80
80
  {
@@ -95,14 +95,14 @@ class Ja3AnomalyDetector
95
95
  }
96
96
 
97
97
  /**
98
- * Calcule le score global d'anomalie et d'usurpation JA3.
98
+ * Evaluates comprehensive JA3 spoofing and anomaly suspicion score.
99
99
  *
100
- * @param string|null $ja3Hash L'empreinte MD5 du JA3 (32 caractères)
101
- * @param string|null $ja3Raw L'empreinte brute non hachée (si disponible)
102
- * @param string $userAgent Le User-Agent de la requête
103
- * @param string $httpVersion La version HTTP de la requête (ex: "HTTP/2", "HTTP/1.1", ou "2.0")
104
- * @param object|null $cacheInstance Un driver de cache (ex: instance Redis) supportant get() et set() pour la détection de stagnation
105
- * @return int Un score de suspicion compris entre 0 et 100
100
+ * @param string|null $ja3Hash MD5 hash of JA3 fingerprint (32 hex characters).
101
+ * @param string|null $ja3Raw Raw unhashed JA3 string if available.
102
+ * @param string $userAgent Request User-Agent header.
103
+ * @param string $httpVersion HTTP protocol version string.
104
+ * @param object|null $cacheInstance Cache store for multi-UA stagnation detection.
105
+ * @return int Suspicion score between 0 and 100.
106
106
  */
107
107
  public static function getJa3AnomalyScore(
108
108
  ?string $ja3Hash,
@@ -115,18 +115,18 @@ class Ja3AnomalyDetector
115
115
  $claimedBrowser = self::getBrowserFamily($userAgent);
116
116
  $isHumanBrowser = in_array($claimedBrowser, ['Chrome', 'Firefox', 'Safari', 'Edge'], true);
117
117
 
118
- // --- ANALYSE 1 : CONTRÔLE SUR LE MD5 DU JA3 ---
118
+ // --- ANALYSIS 1: MD5 JA3 HASH VERIFICATION ---
119
119
  if ($ja3Hash && strlen($ja3Hash) === 32) {
120
120
  if (isset(self::TLS_FINGERPRINT_DB[$ja3Hash])) {
121
121
  $expectedBrowsers = self::TLS_FINGERPRINT_DB[$ja3Hash];
122
122
 
123
- // Cas A : L'empreinte correspond à un outil de scraping mais le UA prétend être humain
123
+ // Case A: TLS matches known automated library but UA claims standard browser
124
124
  $isLibrary = array_intersect($expectedBrowsers, ['Python', 'Go', 'Java', 'curl']);
125
125
  if (!empty($isLibrary) && $isHumanBrowser) {
126
- $score = max($score, 90); // Suspicion maximale : usurpation évidente
126
+ $score = max($score, 90); // High confidence spoofing
127
127
  }
128
128
 
129
- // Cas B : Incohérence directe entre le UA prétendu et la stack TLS correspondante
129
+ // Case B: Direct discrepancy between claimed browser and expected TLS stack
130
130
  if ($claimedBrowser !== null) {
131
131
  $matched = false;
132
132
  foreach ($expectedBrowsers as $expected) {
@@ -136,12 +136,12 @@ class Ja3AnomalyDetector
136
136
  }
137
137
  }
138
138
  if (!$matched) {
139
- $score = max($score, 80); // Le navigateur déclaré ne correspond pas au client TLS utilisé
139
+ $score = max($score, 80); // Browser family mismatch
140
140
  }
141
141
  }
142
142
  }
143
143
 
144
- // Cas C : Tracking de stagnation multi-UA (Stateful)
144
+ // Case C: Stateful multi-UA stagnation detection
145
145
  if ($cacheInstance && $claimedBrowser !== null && method_exists($cacheInstance, 'get') && method_exists($cacheInstance, 'set')) {
146
146
  $cacheKey = "ja3-browsers:" . $ja3Hash;
147
147
 
@@ -154,7 +154,7 @@ class Ja3AnomalyDetector
154
154
 
155
155
  if (!in_array($claimedBrowser, $seenBrowsers, true)) {
156
156
  $seenBrowsers[] = $claimedBrowser;
157
- // Cache pendant 24 heures (86400 secondes)
157
+ // Cache for 24 hours (86400 seconds)
158
158
  if (method_exists($cacheInstance, 'setex')) {
159
159
  $cacheInstance->setex($cacheKey, 86400, json_encode($seenBrowsers));
160
160
  } else {
@@ -162,32 +162,32 @@ class Ja3AnomalyDetector
162
162
  }
163
163
  }
164
164
 
165
- // Si une seule stack TLS génère des requêtes avec différents navigateurs, c'est un bot en rotation de UA
165
+ // Multiple rotating browser UAs emitted from identical TLS stack
166
166
  if (count($seenBrowsers) > 1) {
167
167
  $score = max($score, 85);
168
168
  }
169
169
  } catch (\Throwable $e) {
170
- // Tolérance aux pannes du cache
170
+ // Fault-tolerant fallback on cache error
171
171
  }
172
172
  }
173
173
  }
174
174
 
175
- // --- ANALYSE 2 : CONTRÔLE PROFOND SUR L'EMPREINTE BRUTE (RAW JA3) ---
175
+ // --- ANALYSIS 2: DEEP INSPECTION ON RAW JA3 STRING ---
176
176
  if ($ja3Raw) {
177
177
  $parsed = self::parseJa3($ja3Raw);
178
178
  if ($parsed) {
179
- // Contrôle A : Mécanisme GREASE pour Chrome / Edge (obligatoire)
179
+ // Check A: GREASE presence for Chrome / Edge
180
180
  if ($claimedBrowser === 'Chrome' || $claimedBrowser === 'Edge') {
181
181
  $hasCiphersGrease = self::hasGrease($parsed['ciphers']);
182
182
  $hasExtensionsGrease = self::hasGrease($parsed['extensions']);
183
183
 
184
184
  if (!$hasCiphersGrease && !$hasExtensionsGrease) {
185
- // Chrome ou Edge moderne sans GREASE = spoofing de bas niveau (ex: python-requests déguisé)
185
+ // Modern Chromium missing GREASE indicates spoofing
186
186
  $score = max($score, 75);
187
187
  }
188
188
  }
189
189
 
190
- // Contrôle B : HTTP/2 ou HTTP/3 sans négociation ALPN (Extension 16)
190
+ // Check B: HTTP/2 or HTTP/3 without ALPN extension (Extension 16)
191
191
  $isH2OrHigher = (
192
192
  strpos($httpVersion, '2.0') !== false ||
193
193
  strpos($httpVersion, 'HTTP/2') !== false ||
@@ -196,11 +196,11 @@ class Ja3AnomalyDetector
196
196
  $hasAlpnExtension = in_array(16, $parsed['extensions'], true);
197
197
 
198
198
  if ($isH2OrHigher && !$hasAlpnExtension) {
199
- // Négociation HTTP/2 active au niveau serveur mais absente au niveau des extensions TLS du client
199
+ // HTTP/2 active at server level but missing from client ALPN extension
200
200
  $score = max($score, 70);
201
201
  }
202
202
 
203
- // Contrôle C : Version TLS obsolète négociée par un navigateur moderne (ex: TLS < 1.2, id < 771)
203
+ // Check C: Deprecated TLS version negotiated by claimed modern browser
204
204
  if ($isHumanBrowser && $parsed['tlsVersion'] < 771) {
205
205
  $score = max($score, 80);
206
206
  }
@@ -211,8 +211,9 @@ class Ja3AnomalyDetector
211
211
  }
212
212
 
213
213
  /**
214
- * Feature 7 : Corrélation RTT & Latence de Proxy Résidentiel
215
- * Calcule un score d'anomalie en corrélant le RTT TCP de bas niveau avec la latence applicative.
214
+ * Correlates low-level TCP RTT with application layer latency to detect residential proxy hops.
215
+ * @param RequestContext $context
216
+ * @return int Suspicion score
216
217
  */
217
218
  public static function getRttProxyScore(RequestContext $context): int
218
219
  {