@anonympins/fingerprint 0.7.5 → 0.8.1

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 (68) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -2
  4. package/public/anonympins-bot-mitigation-pow.zip +0 -0
  5. package/src/js/asn-lookup.js +223 -0
  6. package/src/js/fingerprint.client.js +1784 -1772
  7. package/src/js/fingerprint.client.obfuscated.js +1 -1
  8. package/src/js/fingerprint.js +236 -40
  9. package/src/js/fingerprint.utils.js +252 -2
  10. package/src/js/pow.worker.js +2 -2
  11. package/src/js/tests/asn-lookup.test.js +57 -0
  12. package/src/js/tests/cross_parity.test.js +3 -3
  13. package/src/js/tests/dns-circuit-breaker.test.js +6 -6
  14. package/src/js/tests/fingerprint.builder.test.js +76 -78
  15. package/src/js/tests/fingerprint.client.test.js +16 -17
  16. package/src/js/tests/fingerprint.test.js +19 -12
  17. package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
  18. package/src/js/tests/library.test.js +94 -95
  19. package/src/js/tests/obfuscation.test.js +8 -8
  20. package/src/js/tests/quicFingerprint.test.js +47 -0
  21. package/src/js/tests/tcpFingerprint.test.js +4 -4
  22. package/src/js/upow-model-task.js +155 -112
  23. package/src/php/AsnLookupEngine.php +173 -0
  24. package/src/php/Challenge/ChallengeUtils.php +430 -117
  25. package/src/php/Config/SecurityProfiles.php +75 -62
  26. package/src/php/DirectFingerprint.php +80 -13
  27. package/src/php/FingerprintBuilder.php +13 -13
  28. package/src/php/FingerprintClient.php +6 -6
  29. package/src/php/FingerprintEngine.php +2141 -1837
  30. package/src/php/Ja3AnomalyDetector.php +22 -22
  31. package/src/php/NetworkProfile.php +68 -0
  32. package/src/php/Optimization/FunctionRegistry.php +4 -4
  33. package/src/php/Optimization/Optimization.php +6 -6
  34. package/src/php/ProblemManager.php +12 -2
  35. package/src/php/RequestContext.php +11 -11
  36. package/src/php/Store/InMemoryStore.php +96 -66
  37. package/src/php/Store/MongoDbStore.php +2 -2
  38. package/src/php/Store/RedisStore.php +1 -1
  39. package/src/php/Store/StoreManager.php +48 -35
  40. package/src/php/Tests/AsnLookupTest.php +61 -0
  41. package/src/php/Tests/ChallengeUtilsTest.php +31 -0
  42. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  43. package/src/php/Tests/FingerprintClientTest.php +70 -70
  44. package/src/php/Tests/FingerprintEngineTest.php +4 -2
  45. package/src/php/Tests/PowTest.php +37 -39
  46. package/src/php/Tests/QuicFingerprintTest.php +115 -53
  47. package/src/php/Tests/ThreatIntelTest.php +2 -2
  48. package/src/php/Utils/BigInt.php +202 -202
  49. package/src/php/Utils/BlockList.php +13 -13
  50. package/src/php/Utils/Env.php +7 -2
  51. package/src/php/Utils/Logger.php +19 -1
  52. package/src/php/Utils/MaliciousPatterns.php +6 -6
  53. package/src/php/Utils/MetricsManager.php +209 -209
  54. package/src/php/Utils/RequestUtils.php +348 -18
  55. package/src/php/Utils/TLSClientHelloParser.php +385 -369
  56. package/src/php/WordPress/WpDbStore.php +34 -7
  57. package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1222 -0
  58. package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
  59. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
  60. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -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 +297 -261
  63. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  64. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
  65. package/src/php/WordPress/package.php +88 -99
  66. package/src/php/WordPress/readme.txt +28 -6
  67. package/src/php/bin/auto-tune.php +1 -1
  68. package/public/fingerprint-anti-bot.zip +0 -0
@@ -1,1020 +1,1231 @@
1
1
  <?php
2
2
 
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint;
6
-
7
- use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
- use Anonympins\Fingerprint\Config\SecurityProfiles;
9
- use Anonympins\Fingerprint\Store\StoreManager;
10
- use Anonympins\Fingerprint\Utils\BlockList;
11
- use Anonympins\Fingerprint\Utils\Logger;
12
- use Anonympins\Fingerprint\Utils\MetricsManager;
13
- use Anonympins\Fingerprint\Utils\RequestUtils;
14
- use Anonympins\Fingerprint\Utils\Env;
15
-
16
- // Correction de l'import
17
-
18
- /**
19
- * Le moteur principal de la bibliothèque de fingerprinting.
20
- * Orchestre l'identification, le calcul de suspicion et la gestion des challenges.
21
- */
22
- class FingerprintEngine
23
- {
24
- private array $securityConfig;
25
- private bool $isProduction;
26
- private BlockList $allowlist;
27
- private bool $verbose;
28
- private ?Logger $logger = null;
29
- private bool $dryRun;
30
-
31
- private static ?array $googlebotEntries = null;
32
- private static ?array $yandexEntries = null;
33
- private static ?array $bingbotEntries = null;
34
- private static ?array $facebookEntries = null;
35
-
36
- private static array $dnsCircuitBreaker = [
37
- 'state' => 'CLOSED',
38
- 'failureCount' => 0,
39
- 'lastStateChange' => 0,
40
- 'threshold' => 5,
41
- 'cooldown' => 30, // seconds
42
- ];
43
-
44
- private static function recordDnsSuccess(): void
45
- {
46
- self::$dnsCircuitBreaker['failureCount'] = 0;
47
- self::$dnsCircuitBreaker['state'] = 'CLOSED';
48
- }
49
-
50
- private static function recordDnsFailure(): void
51
- {
52
- self::$dnsCircuitBreaker['failureCount']++;
53
- if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
54
- self::$dnsCircuitBreaker['state'] = 'OPEN';
55
- self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
56
- }
57
- }
58
-
59
- private static function canAttemptDns(): bool
60
- {
61
- if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
62
- return true;
63
- }
64
- if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
65
- if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
66
- self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
67
- return true;
68
- }
69
- return false;
70
- }
71
- return true; // HALF-OPEN
72
- }
73
-
74
- private static function getReverseDnsName(string $ip): ?string
75
- {
76
- if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
77
- return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
78
- } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
79
- $hex = bin2hex((string)inet_pton($ip));
80
- return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
81
- }
82
- return null;
83
- }
84
-
85
- private static function resolveDnsNative(string $query, string $type): ?array
86
- {
87
- $typeMap = [
88
- 'PTR' => DNS_PTR,
89
- 'A' => DNS_A,
90
- 'AAAA' => DNS_AAAA
91
- ];
92
- $dnsType = $typeMap[$type] ?? null;
93
- if ($dnsType === null) {
94
- return null;
95
- }
96
-
97
- $records = @dns_get_record($query, $dnsType);
98
- if ($records === false) {
99
- return null;
100
- }
101
-
102
- $results = [];
103
- foreach ($records as $record) {
104
- if ($type === 'PTR' && isset($record['target'])) {
105
- $results[] = $record['target'];
106
- } elseif ($type === 'A' && isset($record['ip'])) {
107
- $results[] = $record['ip'];
108
- } elseif ($type === 'AAAA' && isset($record['ipv6'])) {
109
- $results[] = $record['ipv6'];
110
- }
111
- }
112
- return $results;
113
- }
114
-
115
- public function __construct(array $securityConfig)
116
- {
117
- $this->isProduction = Env::get('APP_ENV') === 'production';
118
-
119
- // Dynamically bind Ed25519 keys if passed via config
120
- if (isset($securityConfig['ed25519_private_key'])) {
121
- Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
122
- }
123
- if (isset($securityConfig['ed25519_public_key'])) {
124
- Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
125
- }
126
-
127
- // Génération et persistance automatique de la paire de clés Ed25519
128
- $useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
129
- $envPrivate = Env::get('ED25519_PRIVATE_KEY');
130
- if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
131
- if (function_exists('get_option') && function_exists('update_option')) {
132
- // In WordPress, persist cryptographic keys in the database options table
133
- $storedKeys = get_option('fingerprint_ed25519_keys');
134
- if (is_array($storedKeys) && isset($storedKeys['privateKey'], $storedKeys['publicKey'])) {
135
- Env::set('ED25519_PRIVATE_KEY', $storedKeys['privateKey']);
136
- Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
137
- } elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
138
- $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
139
- if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
140
- $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
141
- $publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
142
- Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
143
- Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
144
- update_option('fingerprint_ed25519_keys', [
145
- 'privateKey' => $privateKeyPem,
146
- 'publicKey' => $publicKeyPem,
147
- ], false);
148
- }
149
- }
150
- } else {
151
- self::initStandaloneEd25519Keys();
152
- }
153
- }
154
-
155
- // Auto-load optimized config if autotuning savePath is specified
156
- if (isset($securityConfig['autotuning']['savePath'])) {
157
- $savePath = $securityConfig['autotuning']['savePath'];
158
- if (file_exists($savePath)) {
159
- try {
160
- $savedConfig = json_decode(file_get_contents($savePath), true);
161
- if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
162
- $securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
163
- }
164
- } catch (\Throwable $e) {
165
- self::logError("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
166
- }
167
- }
168
- }
169
-
170
- // Si aucune liste blanche n'est explicitement fournie, appliquer la liste par défaut.
171
- if (!isset($securityConfig['whitelist'])) {
172
- $securityConfig['whitelist'] = self::default_whitelist();
173
- }
174
-
175
- $this->securityConfig = $securityConfig;
176
- $this->verbose = $securityConfig['verbose'] ?? false;
177
- $this->allowlist = $this->buildAllowlist();
178
- $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
179
- $this->dryRun = $securityConfig['dryRun'] ?? false;
180
- $this->validateConfig($securityConfig);
181
- if ($this->securityConfig['reset'] ?? false) {
182
- $this->resetStore();
183
- }
184
- }
185
-
186
- /**
187
- * Initialise et persiste les cles Ed25519 pour les environnements PHP hors WordPress.
188
- */
189
- private static function initStandaloneEd25519Keys(): void
190
- {
191
- $configDir = dirname(__DIR__, 1) . '/config';
192
- $persistentKeyPath = $configDir . '/ed25519_key.json';
193
-
194
- if (file_exists($persistentKeyPath)) {
195
- try {
196
- $keys = json_decode((string)file_get_contents($persistentKeyPath), true);
197
- if (isset($keys['privateKey'], $keys['publicKey'])) {
198
- Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
199
- Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
200
- }
201
- } catch (\Throwable $e) {
202
- self::logError('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
203
- }
204
- } else {
205
- try {
206
- if (defined('OPENSSL_KEYTYPE_ED25519')) {
207
- $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
208
- if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
209
- $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
210
- $publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
211
- Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
212
- Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
213
- if (!is_dir($configDir)) {
214
- mkdir($configDir, 0777, true);
215
- }
216
- // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- Non-WordPress standalone environment fallback
217
- file_put_contents($persistentKeyPath, json_encode([
218
- 'privateKey' => $privateKeyPem,
219
- 'publicKey' => $publicKeyPem
220
- ], JSON_PRETTY_PRINT));
221
- }
222
- }
223
- } catch (\Throwable $e) {
224
- self::logError('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
225
- }
226
- }
227
- }
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
9
+ use Anonympins\Fingerprint\Store\StoreManager;
10
+ use Anonympins\Fingerprint\Utils\BlockList;
11
+ use Anonympins\Fingerprint\Utils\Logger;
12
+ use Anonympins\Fingerprint\Utils\MetricsManager;
13
+ use Anonympins\Fingerprint\Utils\RequestUtils;
14
+ use Anonympins\Fingerprint\Utils\Env;
15
+
16
+ /**
17
+ * The main engine of the fingerprinting library.
18
+ * Orchestrates identification, suspicion calculation, and challenge management.
19
+ */
20
+ class FingerprintEngine
21
+ {
22
+ private array $securityConfig;
23
+ private bool $isProduction;
24
+ private BlockList $allowlist;
25
+ private bool $verbose;
26
+ private ?Logger $logger = null;
27
+ private bool $dryRun;
28
+
29
+ private static ?array $googlebotEntries = null;
30
+ private static ?array $yandexEntries = null;
31
+ private static ?array $bingbotEntries = null;
32
+ private static ?array $facebookEntries = null;
33
+
34
+ private static array $dnsCircuitBreaker = [
35
+ 'state' => 'CLOSED',
36
+ 'failureCount' => 0,
37
+ 'lastStateChange' => 0,
38
+ 'threshold' => 5,
39
+ 'cooldown' => 30, // seconds
40
+ ];
228
41
 
229
42
  /**
230
- * Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
231
- * sans nécessiter de redémarrage.
43
+ * Records a successful DNS lookup, resetting the circuit breaker to CLOSED.
232
44
  *
233
- * @param array $newConfig La nouvelle configuration (partielle ou complète).
45
+ * @return void
46
+ */
47
+ private static function recordDnsSuccess(): void
48
+ {
49
+ self::$dnsCircuitBreaker['failureCount'] = 0;
50
+ self::$dnsCircuitBreaker['state'] = 'CLOSED';
51
+ }
52
+
53
+ /**
54
+ * Records a DNS lookup failure. If the failure count reaches the threshold,
55
+ * the circuit breaker transitions to the OPEN state.
56
+ *
57
+ * @return void
58
+ */
59
+ private static function recordDnsFailure(): void
60
+ {
61
+ self::$dnsCircuitBreaker['failureCount']++;
62
+ if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
63
+ self::$dnsCircuitBreaker['state'] = 'OPEN';
64
+ self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Determines whether a DNS attempt is allowed based on the circuit breaker state.
70
+ * In OPEN state, allows a retry only after the cooldown has elapsed (HALF-OPEN).
71
+ *
72
+ * @return bool True if a DNS attempt may be made, false otherwise.
73
+ */
74
+ private static function canAttemptDns(): bool
75
+ {
76
+ if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
77
+ return true;
78
+ }
79
+ if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
80
+ if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
81
+ self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+ return true; // HALF-OPEN
87
+ }
88
+
89
+ /**
90
+ * Builds the reverse DNS query name for a given IP address
91
+ * (in-addr.arpa for IPv4, ip6.arpa for IPv6).
92
+ *
93
+ * @param string $ip The IP address.
94
+ * @return string|null The reverse DNS name, or null if the IP is invalid.
95
+ */
96
+ private static function getReverseDnsName(string $ip): ?string
97
+ {
98
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
99
+ return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
100
+ } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
101
+ $hex = bin2hex((string)inet_pton($ip));
102
+ return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Resolves a DNS query using PHP's native `dns_get_record` function.
109
+ * Supports PTR, A, and AAAA record types.
110
+ *
111
+ * @param string $query The DNS query name.
112
+ * @param string $type The record type ('PTR', 'A', or 'AAAA').
113
+ * @return array|null The list of resolved records, or null on failure.
114
+ */
115
+ private static function resolveDnsNative(string $query, string $type): ?array
116
+ {
117
+ $typeMap = [
118
+ 'PTR' => DNS_PTR,
119
+ 'A' => DNS_A,
120
+ 'AAAA' => DNS_AAAA
121
+ ];
122
+ $dnsType = $typeMap[$type] ?? null;
123
+ if ($dnsType === null) {
124
+ return null;
125
+ }
126
+
127
+ $records = @dns_get_record($query, $dnsType);
128
+ if ($records === false) {
129
+ return null;
130
+ }
131
+
132
+ $results = [];
133
+ foreach ($records as $record) {
134
+ if ($type === 'PTR' && isset($record['target'])) {
135
+ $results[] = $record['target'];
136
+ } elseif ($type === 'A' && isset($record['ip'])) {
137
+ $results[] = $record['ip'];
138
+ } elseif ($type === 'AAAA' && isset($record['ipv6'])) {
139
+ $results[] = $record['ipv6'];
140
+ }
141
+ }
142
+ return $results;
143
+ }
144
+
145
+ /**
146
+ * Creates a new FingerprintEngine with the given security configuration.
147
+ * Handles Ed25519 key binding/generation, autotuning config loading,
148
+ * default whitelist application, config validation, and optional store reset.
149
+ *
150
+ * @param array $securityConfig The security configuration.
151
+ */
152
+ public function __construct(array $securityConfig)
153
+ {
154
+ $this->isProduction = Env::get('APP_ENV') === 'production';
155
+
156
+ // Dynamically bind Ed25519 keys if passed via config
157
+ if (isset($securityConfig['ed25519_private_key'])) {
158
+ Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
159
+ }
160
+ if (isset($securityConfig['ed25519_public_key'])) {
161
+ Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
162
+ }
163
+
164
+ // Automatic generation and persistence of the Ed25519 key pair
165
+ $useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
166
+ $envPrivate = Env::get('ED25519_PRIVATE_KEY');
167
+ if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
168
+ if (function_exists('get_option') && function_exists('update_option')) {
169
+ // In WordPress, persist cryptographic keys in the database options table
170
+ $storedKeys = get_option('fingerprint_ed25519_keys');
171
+ if (is_array($storedKeys) && isset($storedKeys['privateKey'], $storedKeys['publicKey'])) {
172
+ Env::set('ED25519_PRIVATE_KEY', $storedKeys['privateKey']);
173
+ Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
174
+ } elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
175
+ $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
176
+ if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
177
+ $details = openssl_pkey_get_details($pkey);
178
+ $publicKeyPem = $details['key'] ?? '';
179
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
180
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
181
+ }
182
+ update_option('fingerprint_ed25519_keys', [
183
+ 'privateKey' => $privateKeyPem,
184
+ 'publicKey' => $publicKeyPem,
185
+ ], false);
186
+ }
187
+ } else {
188
+ self::initStandaloneEd25519Keys();
189
+ }
190
+ }
191
+
192
+ // Auto-load optimized config if autotuning savePath is specified
193
+ if (isset($securityConfig['autotuning']['savePath'])) {
194
+ $savePath = $securityConfig['autotuning']['savePath'];
195
+ if (file_exists($savePath)) {
196
+ try {
197
+ $savedConfig = json_decode(file_get_contents($savePath), true);
198
+ if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
199
+ $securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
200
+ }
201
+ } catch (\Throwable $e) {
202
+ self::logError("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
203
+ }
204
+ }
205
+ }
206
+
207
+ // If no allowlist is explicitly provided, apply the default list.
208
+ if (!isset($securityConfig['whitelist'])) {
209
+ $securityConfig['whitelist'] = self::default_whitelist();
210
+ }
211
+
212
+ $this->securityConfig = $securityConfig;
213
+ $this->verbose = $securityConfig['verbose'] ?? false;
214
+ $this->allowlist = $this->buildAllowlist();
215
+ $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
216
+ $this->dryRun = $securityConfig['dryRun'] ?? false;
217
+ $this->validateConfig($securityConfig);
218
+ if ($this->securityConfig['reset'] ?? false) {
219
+ $this->resetStore();
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Initializes and persists Ed25519 keys for standalone PHP environments outside WordPress.
225
+ * Loads from a persistent JSON file if it exists, otherwise generates a new key pair.
226
+ *
227
+ * @return void
228
+ */
229
+ private static function initStandaloneEd25519Keys(): void
230
+ {
231
+ if (function_exists('get_option')) {
232
+ return; // Managed exclusively via WordPress options in WP runtime
233
+ }
234
+
235
+ if (function_exists('wp_upload_dir') && function_exists('wp_mkdir_p')) {
236
+ $uploadDir = wp_upload_dir();
237
+ $configDir = (!empty($uploadDir['basedir']) && is_dir($uploadDir['basedir']))
238
+ ? $uploadDir['basedir'] . '/anonympins-bot-mitigation-pow'
239
+ : sys_get_temp_dir();
240
+ } else {
241
+ $envKeyDir = Env::get('FINGERPRINT_KEY_DIR');
242
+ $configDir = !empty($envKeyDir) ? (string)$envKeyDir : sys_get_temp_dir();
243
+ }
244
+ $persistentKeyPath = rtrim($configDir, '/\\') . '/ed25519_key.json';
245
+
246
+ if (file_exists($persistentKeyPath)) {
247
+ try {
248
+ $raw = (string)file_get_contents($persistentKeyPath);
249
+ $keys = json_decode($raw, true);
250
+ if (isset($keys['privateKey'], $keys['publicKey'])) {
251
+ Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
252
+ Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
253
+ }
254
+ } catch (\Throwable $e) {
255
+ self::logError('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
256
+ }
257
+ } else {
258
+ try {
259
+ if (defined('OPENSSL_KEYTYPE_ED25519')) {
260
+ $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
261
+ if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
262
+ $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
263
+ $publicKeyPem = $details['key'] ?? ''; // @phpstan-ignore-line
264
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
265
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
266
+ if (!is_dir($configDir)) {
267
+ if (function_exists('wp_mkdir_p')) {
268
+ wp_mkdir_p($configDir);
269
+ } else {
270
+ @mkdir($configDir, 0700, true);
271
+ }
272
+ }
273
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
274
+ file_put_contents($persistentKeyPath, json_encode([
275
+ 'privateKey' => $privateKeyPem,
276
+ 'publicKey' => $publicKeyPem
277
+ ], JSON_PRETTY_PRINT));
278
+ }
279
+ }
280
+ } catch (\Throwable $e) {
281
+ self::logError('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Hot-reloads a new security configuration (weights, thresholds, etc.)
288
+ * without requiring a restart.
289
+ *
290
+ * @param array $newConfig The new configuration (partial or complete).
234
291
  */
235
292
  public function updateConfig(array $newConfig): void
236
293
  {
237
- $this->validateConfig($newConfig);
238
- $this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
239
- $this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
294
+ $this->validateConfig($newConfig);
295
+ $this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
296
+ $this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
297
+ }
298
+
299
+ /**
300
+ * Resets the active persistence store.
301
+ */
302
+ public function resetStore(): void
303
+ {
304
+ $store = StoreManager::getStore();
305
+ if (method_exists($store, 'clear')) {
306
+ $store->clear();
307
+ }
308
+ $this->log('Store has been reset/cleared.');
309
+ }
310
+
311
+ /**
312
+ * Validates a security configuration. Warns about missing or unknown keys
313
+ * (e.g. weights, thresholds, typos).
314
+ *
315
+ * @param array $config The configuration to validate.
316
+ * @return void
317
+ */
318
+ private function validateConfig(array $config): void
319
+ {
320
+ if (empty($config)) {
321
+ $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
322
+ return;
323
+ }
324
+
325
+ $knownKeys = [
326
+ 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
327
+ 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
328
+ 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
329
+ 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
330
+ 'similarityThreshold', 'summary', 'description',
331
+ 'ed25519_private_key', 'ed25519_public_key',
332
+ 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset',
333
+ 'filterWhitelist',
334
+ 'differentialPrivacy', 'dpEpsilon'
335
+ ,'pat'
336
+ ];
337
+
338
+ if (empty($config['weights'])) {
339
+ $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
340
+ }
341
+ if (empty($config['thresholds'])) {
342
+ $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
343
+ }
344
+
345
+ foreach (array_keys($config) as $key) {
346
+ if (!in_array($key, $knownKeys, true)) {
347
+ $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Loads a bot whitelist from a JSON file in the config directory.
354
+ * Falls back to the provided default entries if the file is missing or invalid.
355
+ *
356
+ * @param string $filename The whitelist JSON filename.
357
+ * @param array $fallbackEntries The fallback entries if loading fails.
358
+ * @return array The loaded or fallback whitelist entries.
359
+ */
360
+ private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
361
+ {
362
+ if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
363
+ $configDir = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config';
364
+ } elseif (is_dir(__DIR__ . '/config')) {
365
+ $configDir = __DIR__ . '/config';
366
+ } else {
367
+ $configDir = __DIR__;
368
+ }
369
+ $filePath = $configDir . '/' . $filename;
370
+ if (file_exists($filePath)) {
371
+ try {
372
+ $content = file_get_contents($filePath);
373
+ if ($content !== false) {
374
+ $decoded = json_decode($content, true);
375
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
376
+ return $decoded;
377
+ }
378
+ }
379
+ } catch (\Throwable $e) {
380
+ self::logError("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
381
+ }
382
+ }
383
+ return $fallbackEntries;
384
+ }
385
+
386
+ /**
387
+ * Returns the Googlebot allowlist definition (type + entries).
388
+ * The entries are lazily loaded and cached in a static property.
389
+ *
390
+ * @return array The Googlebot allowlist rule.
391
+ */
392
+ public static function googlebot_whitelist(): array
393
+ {
394
+ if (self::$googlebotEntries === null) {
395
+ self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
396
+ "2001:4860:4801:10::/64",
397
+ "2001:4860:4801:11::/64",
398
+ "2001:4860:4801:12::/64",
399
+ "66.249.79.64"
400
+ ]);
401
+ }
402
+ return [
403
+ 'type' => 'allowlist',
404
+ 'entries' => self::$googlebotEntries
405
+ ];
406
+ }
407
+
408
+ /**
409
+ * Returns the Yandex allowlist definition (type + entries).
410
+ * The entries are lazily loaded and cached in a static property.
411
+ *
412
+ * @return array The Yandex allowlist rule.
413
+ */
414
+ public static function yandex_whitelist(): array
415
+ {
416
+ if (self::$yandexEntries === null) {
417
+ self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
418
+ "2a02:6b8::/29",
419
+ "5.45.192.0/18",
420
+ "213.180.192.0/19"
421
+ ]);
422
+ }
423
+ return [
424
+ 'type' => 'allowlist',
425
+ 'entries' => self::$yandexEntries
426
+ ];
427
+ }
428
+
429
+ /**
430
+ * Returns the Bingbot allowlist definition (type + entries).
431
+ * The entries are lazily loaded and cached in a static property.
432
+ *
433
+ * @return array The Bingbot allowlist rule.
434
+ */
435
+ public static function bingbot_whitelist(): array
436
+ {
437
+ if (self::$bingbotEntries === null) {
438
+ self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
439
+ "157.55.39.0/24",
440
+ "207.46.13.0/24",
441
+ "40.77.178.0/23"
442
+ ]);
443
+ }
444
+ return [
445
+ 'type' => 'allowlist',
446
+ 'entries' => self::$bingbotEntries
447
+ ];
448
+ }
449
+
450
+ /**
451
+ * Returns the Facebook allowlist definition (type + entries).
452
+ * The entries are lazily loaded and cached in a static property.
453
+ *
454
+ * @return array The Facebook allowlist rule.
455
+ */
456
+ public static function facebook_whitelist(): array
457
+ {
458
+ if (self::$facebookEntries === null) {
459
+ self::$facebookEntries = self::loadBotWhitelist('facebook.json', [
460
+ "31.13.64.0/18", "66.220.144.0/20", "69.63.176.0/20", "157.240.0.0/16"
461
+ ]);
462
+ }
463
+ return [
464
+ 'type' => 'allowlist',
465
+ 'entries' => self::$facebookEntries
466
+ ];
467
+ }
468
+
469
+ /**
470
+ * Returns the complete default whitelist, combining IP allowlists for major
471
+ * bots (Googlebot, Bingbot, Yandex, Facebook) with hostname-suffix rules
472
+ * for many known crawlers.
473
+ *
474
+ * @return array The default whitelist rules.
475
+ */
476
+ public static function default_whitelist(): array
477
+ {
478
+ return [
479
+ self::googlebot_whitelist(),
480
+ self::bingbot_whitelist(),
481
+ self::yandex_whitelist(),
482
+ self::facebook_whitelist(),
483
+ ['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
484
+ ['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
485
+ ['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
486
+ ['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
487
+ ['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
488
+ ['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
489
+ ['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
490
+ ['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
491
+ ['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
492
+ ['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
493
+ ['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
494
+ ['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
495
+ ['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
496
+ ['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
497
+ ['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
498
+ ['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
499
+ ['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
500
+ ['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
501
+ ['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
502
+ ['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
503
+ ['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
504
+ ['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
505
+ ['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
506
+ ];
507
+ }
508
+
509
+ /**
510
+ * Emits a log message if verbose mode is enabled. Uses the configured
511
+ * logger callback if available, otherwise falls back to error_log.
512
+ *
513
+ * @param string $message The message to log.
514
+ * @param array $data Additional data to include.
515
+ * @param string $level The log level (default 'info').
516
+ * @return void
517
+ */
518
+ private function log(string $message, array $data = [], string $level = 'info'): void
519
+ {
520
+ if ($this->verbose) {
521
+ // Uses the logger if configured, otherwise error_log
522
+ if ($this->logger) {
523
+ $this->logger->log($level, "[FingerprintEngine] " . $message, $data);
524
+ } else {
525
+ $logMessage = "[FingerprintEngine] {$message}";
526
+ if (!empty($data)) $logMessage .= ' ' . json_encode($data);
527
+ self::logError($logMessage);
528
+ }
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Computes the final suspicion score from a suspicion vector by applying
534
+ * the configured weights. When an MTU anomaly (tunnel) is detected, several
535
+ * weights are dynamically amplified to increase the score.
536
+ *
537
+ * @param array $suspicionVector The suspicion vector (metric name => score).
538
+ * @return float The final score, capped at 100.0.
539
+ */
540
+ public function calculateFinalScore(array $suspicionVector): float
541
+ {
542
+ $baseWeights = $this->securityConfig['weights'] ?? [];
543
+
544
+ $score = 0.0;
545
+ // NEW: Weight amplification logic
546
+ // If a tunnel is detected, increase the weight of other suspicious signals.
547
+ $dynamicWeights = $baseWeights;
548
+ if (($suspicionVector['mtuAnomalyScore'] ?? 0.0) > 50.0) {
549
+ $this->log('Tunnel detected, amplifying suspicion weights.', ['mtuScore' => $suspicionVector['mtuAnomalyScore']]);
550
+
551
+ // Increases the weight of low-level inconsistencies (hard to falsify)
552
+ $dynamicWeights['tlsSpoofingScore'] = ($baseWeights['tlsSpoofingScore'] ?? 0.8) * 1.25;
553
+ $dynamicWeights['crossLayerInconsistencyScore'] = ($baseWeights['crossLayerInconsistencyScore'] ?? 0.4) * 1.4;
554
+ $dynamicWeights['clientHintsInconsistencyScore'] = ($baseWeights['clientHintsInconsistencyScore'] ?? 0.7) * 1.2;
555
+
556
+ // Increases the weight of behavioral anomalies (a bot behind a VPN is more suspect)
557
+ $dynamicWeights['behaviorScore'] = ($baseWeights['behaviorScore'] ?? 0.7) * 1.15;
558
+ $dynamicWeights['requestPatternScore'] = ($baseWeights['requestPatternScore'] ?? 0.6) * 1.2;
559
+ }
560
+
561
+ foreach ($dynamicWeights as $key => $weight) {
562
+ $metricScore = $suspicionVector[$key] ?? 0.0;
563
+ if ($metricScore > 0) {
564
+ $score += $metricScore * $weight;
565
+ }
566
+ }
567
+
568
+ return min(100.0, $score);
569
+ }
570
+
571
+ /**
572
+ * Builds the internal BlockList used for IP allowlist checks, from the
573
+ * 'allowlist' rule found in the whitelist configuration.
574
+ *
575
+ * @return BlockList The populated allowlist.
576
+ */
577
+ private function buildAllowlist(): BlockList
578
+ {
579
+ $blockList = new BlockList();
580
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
581
+
582
+ $allowlistRule = null;
583
+ foreach ($whitelistRules as $rule) {
584
+ if (($rule['type'] ?? '') === 'allowlist') {
585
+ $allowlistRule = $rule;
586
+ break;
587
+ }
588
+ }
589
+
590
+ if (empty($allowlistRule['entries'])) {
591
+ return $blockList;
592
+ }
593
+
594
+ foreach ($allowlistRule['entries'] as $entry) {
595
+ $blockList->add($entry);
596
+ }
597
+ return $blockList;
598
+ }
599
+
600
+ /**
601
+ * Checks whether the given client IP is in the IP allowlist.
602
+ *
603
+ * @param string $clientIp The client IP address.
604
+ * @return bool True if the IP is allowlisted, false otherwise.
605
+ */
606
+ private function isIpInAllowlist(string $clientIp): bool
607
+ {
608
+ return $this->allowlist->check($clientIp);
609
+ }
610
+
611
+ /**
612
+ * Checks whether the given request path is in the path allowlist.
613
+ * Supports trailing-wildcard entries (e.g. "/admin/*").
614
+ *
615
+ * @param string $requestPath The request path.
616
+ * @return bool True if the path is allowlisted, false otherwise.
617
+ */
618
+ private function isPathInAllowlist(string $requestPath): bool
619
+ {
620
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
621
+ $pathAllowlistRule = null;
622
+ foreach ($whitelistRules as $rule) {
623
+ if (($rule['type'] ?? '') === 'path_allowlist') {
624
+ $pathAllowlistRule = $rule;
625
+ break;
626
+ }
627
+ }
628
+
629
+ if (empty($pathAllowlistRule['entries'])) {
630
+ return false;
631
+ }
632
+
633
+ foreach ($pathAllowlistRule['entries'] as $entry) {
634
+ if (str_ends_with($entry, '*')) {
635
+ $base = substr($entry, 0, -1);
636
+ if (str_starts_with($requestPath, $base)) {
637
+ return true;
638
+ }
639
+ } elseif ($requestPath === $entry) {
640
+ return true;
641
+ }
642
+ }
643
+
644
+ return false;
645
+ }
646
+
647
+ /**
648
+ * Checks whether the (host, path) pair matches a host_path_allowlist entry.
649
+ *
650
+ * @param string|null $requestHost The request host.
651
+ * @param string $requestPath The request path.
652
+ * @return bool True if the host+path is allowlisted, false otherwise.
653
+ */
654
+ private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
655
+ {
656
+ if (empty($requestHost)) {
657
+ return false;
658
+ }
659
+
660
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
661
+ $hostPathRule = null;
662
+ foreach ($whitelistRules as $rule) {
663
+ if (($rule['type'] ?? '') === 'host_path_allowlist') {
664
+ $hostPathRule = $rule;
665
+ break;
666
+ }
667
+ }
668
+
669
+ if (empty($hostPathRule['entries'])) {
670
+ return false;
671
+ }
672
+
673
+ foreach ($hostPathRule['entries'] as $entry) {
674
+ if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
675
+ return true;
676
+ }
677
+ }
678
+ return false;
679
+ }
680
+
681
+ /**
682
+ * Checks if a request should be exempted due to an allowlist rule.
683
+ * Evaluates IP, path, host+path, GraphQL operation, and verified bot rules.
684
+ * Applies the `filterWhitelist` security option to optionally override
685
+ * allowlisting for clearly malicious requests.
686
+ *
687
+ * @param RequestContext $context The request context.
688
+ * @return bool True if the request should be allowed, false otherwise.
689
+ */
690
+ private function checkAllowlists(RequestContext $context): bool
691
+ {
692
+ $whitelisted = false;
693
+ $type = '';
694
+
695
+ if ($this->isIpInAllowlist($context->clientIp)) {
696
+ $whitelisted = true;
697
+ $type = 'allowlist';
698
+ } elseif ($this->isPathInAllowlist($context->path)) {
699
+ $whitelisted = true;
700
+ $type = 'path_allowlist';
701
+ } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
702
+ $whitelisted = true;
703
+ $type = 'host_path_allowlist';
704
+ } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
705
+ $whitelisted = true;
706
+ $type = 'graphql_operation_allowlist';
707
+ } elseif ($this->verifyWhitelistedBot($context)) {
708
+ $whitelisted = true;
709
+ $type = 'bot';
710
+ }
711
+
712
+ if ($whitelisted) {
713
+ $filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
714
+ $bypassWhitelist = false;
715
+ if ($filterWhitelist === true || (is_numeric($filterWhitelist) && $type === 'bot')) {
716
+ $bypassWhitelist = $this->hasCertainAttack($context);
717
+ } elseif (is_numeric($filterWhitelist)) {
718
+ if ($context->preCalculatedScore === null) {
719
+ $suspicionVector = [];
720
+ $context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
721
+ $context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
722
+ }
723
+ if ($context->preCalculatedScore > $filterWhitelist) {
724
+ $bypassWhitelist = true;
725
+ }
726
+ }
727
+
728
+ if ($bypassWhitelist) {
729
+ $this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
730
+ return false;
731
+ }
732
+ $this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
733
+ return true;
734
+ }
735
+
736
+ return false;
737
+ }
738
+
739
+ /**
740
+ * Resolves the request identity (device ID, device data, and possibly a
741
+ * new cookie to set). Attempts cookieless identity recovery via TLS session
742
+ * ID, detects cookie dropping, and validates WebAuthn hardware anchors.
743
+ *
744
+ * @param RequestContext $context The request context.
745
+ * @param array &$suspicionVector The suspicion vector (updated in place).
746
+ * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
747
+ */
748
+ private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
749
+ {
750
+ if ($context->resolvedIdentity !== null) {
751
+ return $context->resolvedIdentity;
752
+ }
753
+ $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
754
+ $store = StoreManager::getStore();
755
+ $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
756
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
757
+ $pendingCookieTtl = 120; // 2 minutes to detect removal
758
+
759
+ $deviceId = $existingDeviceId;
760
+ $deviceData = null;
761
+ $newCookie = null;
762
+
763
+ // Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
764
+ $tlsSessionId = $context->tlsSessionId;
765
+ if (!$deviceId && $tlsSessionId) {
766
+ $resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
767
+ if ($resumedDeviceId) {
768
+ $deviceId = $resumedDeviceId;
769
+ $this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
770
+ }
771
+ }
772
+
773
+ if ($deviceId) {
774
+ $deviceData = $store->get("device:{$deviceId}");
775
+ }
776
+
777
+ $ua = $context->getHeader('user-agent') ?? '';
778
+ $pendingKey = "pending_cookie:" . md5($context->clientIp . '|' . $ua);
779
+
780
+ if ($deviceData === null) {
781
+ // New user or lost/invalid cookie
782
+ $pendingDeviceId = $store->get($pendingKey);
783
+ if ($pendingDeviceId && !$existingDeviceId) {
784
+ // The penalty is now added directly to the suspicion vector.
785
+ $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
786
+ $suspicionVector['cookieDroppingScore'] = 100.0;
787
+ }
788
+
789
+ $deviceId = bin2hex(random_bytes(16)); // UUID-like
790
+
791
+ $isHttps = !empty($context->isHttps);
792
+ $secureOption = $isHttps;
793
+
794
+ // Prepare the cookie to be sent
795
+ $newCookie = [
796
+ 'name' => 'device_id',
797
+ 'value' => $deviceId,
798
+ 'options' => [
799
+ 'httponly' => true,
800
+ 'secure' => $secureOption,
801
+ 'samesite' => 'Lax',
802
+ 'path' => '/',
803
+ ]
804
+ ];
805
+ if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
806
+ $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
807
+ }
808
+
809
+ $store->set($pendingKey, $deviceId, $pendingCookieTtl);
810
+
811
+ $deviceData = [
812
+ 'initialDeviceHash' => $currentDeviceHash,
813
+ 'ips' => [$context->clientIp],
814
+ 'requestHistory' => [],
815
+ 'lastUpdate' => time() * 1000,
816
+ 'lastFpHash' => $currentDeviceHash,
817
+ 'lastChangeTimestamp' => 0,
818
+ 'rapidChangeCount' => 0,
819
+ 'highScoreCount' => 0,
820
+ 'lastHighScoreTimestamp' => 0,
821
+ ];
822
+ } else {
823
+ // Ensure 'ips' is an array for the following operations.
824
+ if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
825
+ $deviceData['ips'] = [];
826
+ }
827
+ // The client returned a valid cookie: purge the pending entry immediately
828
+ $store->delete($pendingKey);
829
+ }
830
+
831
+ // WebAuthn hardware anchor (Secure Enclave / TPM) validation
832
+ $behaviorHeader = $context->getHeader('x-behavior-metrics');
833
+ if (!empty($behaviorHeader) && is_string($behaviorHeader) && str_starts_with($behaviorHeader, '{')) {
834
+ try {
835
+ $parsedMetrics = json_decode($behaviorHeader, true);
836
+ if (is_array($parsedMetrics) && !empty($parsedMetrics['webauthnAnchor']) && is_array($deviceData)) {
837
+ if ($this->verifyWebAuthnHardwareAnchor($parsedMetrics['webauthnAnchor'], $deviceData)) {
838
+ $deviceData['webauthnVerified'] = true;
839
+ $store->set("device:{$deviceId}", $deviceData);
840
+ }
841
+ }
842
+ } catch (\Throwable $e) {
843
+ }
844
+ }
845
+
846
+ // Bind the current TLS session ID to the device ID
847
+ if ($deviceId && $tlsSessionId) {
848
+ $store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
849
+ }
850
+
851
+ $context->resolvedIdentity = [
852
+ 'deviceId' => $deviceId,
853
+ 'deviceData' => $deviceData,
854
+ 'newCookie' => $newCookie,
855
+ ];
856
+ return $context->resolvedIdentity;
857
+ }
858
+
859
+ /**
860
+ * Computes the full suspicion vector for a request by aggregating dozens
861
+ * of indicators (fingerprint consistency, TLS spoofing/JA4, behavioral
862
+ * metrics, threat intel, subnet reputation, protocol/TCP anomalies, etc.).
863
+ * Also updates and persists the device's state in the store.
864
+ *
865
+ * @param RequestContext $context The request context.
866
+ * @param array &$suspicionVector The suspicion vector (updated in place).
867
+ * @return array<string, float> The completed suspicion vector.
868
+ */
869
+ public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
870
+ {
871
+ $store = StoreManager::getStore();
872
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
873
+ $deviceData = $identity['deviceData'];
874
+ $deviceId = $identity['deviceId'];
875
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
876
+ $suspicionVector['honeypotScore'] = 100;
877
+ return $suspicionVector;
878
+ }
879
+
880
+ // If a new cookie must be set, temporarily store it in the context
881
+ // so that the calling code can handle it.
882
+ if ($identity['newCookie']) {
883
+ // This property is non-standard, prefix it to avoid conflicts.
884
+ $context->newCookieForResponse = $identity['newCookie'];
885
+ }
886
+
887
+ // Periodic device data cleanup
888
+ if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
889
+ $deviceData['ips'] = [];
890
+ $deviceData['rapidChangeCount'] = 0;
891
+ $deviceData['ipTimes'] = [];
892
+ }
893
+ $deviceData['lastUpdate'] = time() * 1000;
894
+
895
+ // --- Computation of the various suspicion scores ---
896
+
897
+ // Fingerprint inconsistency score (moved here to be with the others)
898
+ $netProfile = AsnLookupEngine::getInstance()->lookup($context->clientIp);
899
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
900
+ $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
901
+ $similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? $netProfile->getInflectionPoint());
902
+ $inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
903
+
904
+ $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData, $netProfile);
905
+
906
+ // Header anomaly score
907
+ $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
908
+
909
+ // TLS spoofing score
910
+ $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
911
+ $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
912
+ $virtualizationScore = RequestUtils::getVirtualizationAnomalyScore($context);
913
+
914
+ // Advanced JA4 TLS Inconsistency checks
915
+ $ja4 = $context->getHeader('x-ja4-hash');
916
+ if ($ja4) {
917
+ $spoofedJa4s = [
918
+ 't13d1516h2_8daaf6152771_390237aa04be', // Classic Chrome (curl-impersonate / tls-client)
919
+ 't13d1413h2_bc66258908f0_bc2531da1615', // Static Firefox (curl-impersonate-ff / curl_cffi)
920
+ 't13d1515h2_8daaf6152771_a729e2f67de4', // Static Safari (curl-impersonate-safari / tls-client)
921
+ 't13d1516h2_8daaf6152771_4be0df930c2c', // Chrome alternative (tls-client Go)
922
+ 't12d1516h2_8daaf6152771_390237aa04be', // Spoofed Chrome downgraded to TLS 1.2
923
+ 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Custom OpenSSL scraping bot
924
+ ];
925
+ if (in_array($ja4, $spoofedJa4s, true)) {
926
+ $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
927
+ }
928
+
929
+ $parsedJa4 = $this->parseJa4($ja4);
930
+ if ($parsedJa4) {
931
+ $ua = $context->getHeader('user-agent') ?? '';
932
+ $uaParts = $this->parseUserAgent($ua);
933
+
934
+ // Check 1: ALPN / HTTP Version inconsistency
935
+ $httpVersion = $context->httpVersion ?? '1.1';
936
+ if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
937
+ $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
938
+ if (!$hasProxy) {
939
+ $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
940
+ }
941
+ }
942
+
943
+ // Check 2: OS/Platform vs TLS Capabilities inconsistency
944
+ if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
945
+ $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
946
+ }
947
+
948
+ // Check 3: User-Agent vs JA4 Signature inconsistency
949
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
950
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
951
+ }
952
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
953
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
954
+ }
955
+
956
+ // Check 4: Stagnation (Lack of Entropy / Genericity)
957
+ if (is_string($uaParts['browser'])) {
958
+ $ja4Key = "ja4-browsers:{$ja4}";
959
+ $seenBrowsers = $store->get($ja4Key) ?: [];
960
+ if (!is_array($seenBrowsers)) {
961
+ $seenBrowsers = [];
962
+ }
963
+ $browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
964
+ if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
965
+ $seenBrowsers[] = $browserFamily;
966
+ $store->set($ja4Key, $seenBrowsers, 86400);
967
+ }
968
+ if (count($seenBrowsers) > 1) {
969
+ $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // Time inconsistency score (replay attack)
976
+ $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
977
+
978
+ // Cross-layer inconsistency score (client vs server)
979
+ $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
980
+
981
+ // Request pattern score (scraping, velocity)
982
+ $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
983
+
984
+ // Honeypot score
985
+ $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
986
+
987
+ // Client behavioral metrics score (mouse, keyboard)
988
+ $behavior = RequestUtils::getBehaviorScore($context);
989
+
990
+ // Explicit bot detection score (automation markers)
991
+ $bot = RequestUtils::getBotScore($context);
992
+
993
+ // Click variance score
994
+ $clickVariance = RequestUtils::getClickVarianceScore($context);
995
+
996
+ // Threat Intelligence score
997
+ $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
998
+
999
+ // Client-Hints inconsistency score
1000
+ $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
1001
+
1002
+ // Global fingerprint similarity score (Botnet Clustering)
1003
+ $stableFp = RequestUtils::extractStablePart($currentDeviceHash);
1004
+ $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
1005
+ $botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
1006
+
1007
+ // NEW: IP subnet reputation score
1008
+ $subnetScore = RequestUtils::getSubnetScore($context, $deviceId, $this->securityConfig);
1009
+
1010
+ // TCP/IP stack anomaly score
1011
+ $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
1012
+
1013
+ // Protocol anomaly score (HTTP/2 and QUIC)
1014
+ $protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
1015
+
1016
+ // Display rendering anomaly score (V-Sync)
1017
+ $renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
1018
+
1019
+ // Assembly of the final suspicion vector
1020
+ $suspicionVector = array_merge($suspicionVector, [
1021
+ 'inconsistencyScore' => $inconsistencyScore,
1022
+ 'historyScore' => $behavioral['historyScore'],
1023
+ 'rotationScore' => $behavioral['rotationScore'],
1024
+ 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
1025
+ 'tlsSpoofingScore' => $tlsSpoofingScore,
1026
+ 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
1027
+ 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
1028
+ 'requestPatternScore' => $requestPattern['requestPatternScore'], // This score is now computed
1029
+ 'honeypotScore' => $honeypot['honeypotScore'],
1030
+ 'behaviorScore' => $behavior['behaviorScore'],
1031
+ 'botScore' => $bot['botScore'],
1032
+ 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
1033
+ 'threatIntelScore' => $threatIntel['threatIntelScore'],
1034
+ 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
1035
+ 'subnetScore' => $subnetScore['subnetScore'],
1036
+ 'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
1037
+ 'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
1038
+ 'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
1039
+ 'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
1040
+ 'virtualizationScore' => $virtualizationScore
1041
+ ]);
1042
+
1043
+ // Save the updated device state in the store
1044
+ $store->set("device:{$deviceId}", $deviceData);
1045
+
1046
+ return $suspicionVector;
1047
+ }
1048
+
1049
+ /**
1050
+ * Parse a basic User-Agent string.
1051
+ *
1052
+ * @param string $ua The User-Agent string.
1053
+ * @return array An array with 'browser' and 'os' keys.
1054
+ */
1055
+ private function parseUserAgent(string $ua): array
1056
+ {
1057
+ $result = ['browser' => null, 'os' => null];
1058
+
1059
+ if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
1060
+ $result['browser'] = 'Chrome';
1061
+ if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
1062
+ $result['browser'] .= '/' . $matches[1];
1063
+ }
1064
+ } elseif (str_contains($ua, 'Firefox')) {
1065
+ $result['browser'] = 'Firefox';
1066
+ if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
1067
+ $result['browser'] .= '/' . $matches[1];
1068
+ }
1069
+ } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
1070
+ $result['browser'] = 'Safari';
1071
+ if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
1072
+ $result['browser'] .= '/' . $matches[1];
1073
+ }
1074
+ } elseif (str_contains($ua, 'Edg')) {
1075
+ $result['browser'] = 'Edge';
1076
+ if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
1077
+ $result['browser'] .= '/' . $matches[1];
1078
+ }
1079
+ }
1080
+
1081
+ if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
1082
+ elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
1083
+ elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
1084
+ elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
1085
+ elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
1086
+ elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
1087
+
1088
+ return $result;
240
1089
  }
241
1090
 
242
1091
  /**
243
- * Réinitialise le store de persistance actif.
1092
+ * Parse JA4 string into protocol, version, ALPN etc.
1093
+ *
1094
+ * @param string|null $ja4 The JA4 string.
1095
+ * @return array|null The parsed components, or null if invalid.
244
1096
  */
245
- public function resetStore(): void
1097
+ private function parseJa4(?string $ja4): ?array
1098
+ {
1099
+ if (empty($ja4)) return null;
1100
+ $parts = explode('_', $ja4);
1101
+ $ja4a = $parts[0];
1102
+ if (strlen($ja4a) < 10) return null;
1103
+ return [
1104
+ 'protocol' => $ja4a[0],
1105
+ 'version' => substr($ja4a, 1, 2),
1106
+ 'sni' => $ja4a[3],
1107
+ 'ciphersCount' => (int)substr($ja4a, 4, 2),
1108
+ 'extensionsCount' => (int)substr($ja4a, 6, 2),
1109
+ 'alpn' => substr($ja4a, 8, 2),
1110
+ 'ja4b' => $parts[1] ?? null,
1111
+ 'ja4c' => $parts[2] ?? null
1112
+ ];
1113
+ }
1114
+
1115
+
1116
+ /**
1117
+ * Decodes a polymorphic fingerprint by restoring the original keys from
1118
+ * a mapping of randomized keys.
1119
+ *
1120
+ * @param string $fpString The encoded fingerprint string.
1121
+ * @param array $mapping The mapping containing a `keys` (orig => rand) entry.
1122
+ * @return string The decoded fingerprint with original keys.
1123
+ */
1124
+ private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
1125
+ {
1126
+ if (empty($mapping['keys'])) {
1127
+ return $fpString;
1128
+ }
1129
+ $reverseKeys = array_flip($mapping['keys']);
1130
+ $parts = explode('|', $fpString);
1131
+ $mappedParts = [];
1132
+ foreach ($parts as $part) {
1133
+ $pair = explode(':', $part, 2);
1134
+ if (count($pair) === 2) {
1135
+ $origKey = $reverseKeys[$pair[0]] ?? $pair[0];
1136
+ $mappedParts[] = "{$origKey}:{$pair[1]}";
1137
+ } else {
1138
+ $mappedParts[] = $part;
1139
+ }
1140
+ }
1141
+ return implode('|', $mappedParts);
1142
+ }
1143
+
1144
+ /**
1145
+ * Translates polymorphic (randomized) header names back to their canonical
1146
+ * names (x-device-fingerprint, x-behavior-metrics) and decodes the
1147
+ * fingerprint values using the active mapping.
1148
+ *
1149
+ * @param RequestContext $context The request context (mutated in place).
1150
+ * @return void
1151
+ */
1152
+ private function translatePolymorphicHeaders(RequestContext $context): void
246
1153
  {
247
1154
  $store = StoreManager::getStore();
248
- if (method_exists($store, 'clear')) {
249
- $store->clear();
1155
+ $activeMappings = $store->get('active-polymorphic-mappings') ?: [];
1156
+ if (!is_array($activeMappings)) {
1157
+ return;
250
1158
  }
251
- $this->log('Store has been reset/cleared.');
1159
+
1160
+ foreach ($activeMappings as $mapping) {
1161
+ $devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
1162
+ $behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
1163
+
1164
+ if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
1165
+ $context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
1166
+ if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
1167
+ $context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
1168
+ }
1169
+
1170
+ $clientFp = $context->headers['x-device-fingerprint'];
1171
+ if ($clientFp && is_string($clientFp)) {
1172
+ $context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
1173
+ }
1174
+ break;
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Evaluates whether the request exhibits clear attack characteristics or
1181
+ * has triggered a honeypot (i.e. a certain attack).
1182
+ *
1183
+ * @param RequestContext $context The request context.
1184
+ * @return bool True if a certain attack is detected, false otherwise.
1185
+ */
1186
+ private function hasCertainAttack(RequestContext $context): bool
1187
+ {
1188
+ $honeypotConfig = $this->securityConfig['honeypot'] ?? [];
1189
+ $honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
1190
+ if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
1191
+ return true;
1192
+ }
1193
+ $bot = RequestUtils::getBotScore($context);
1194
+ if (($bot['botScore'] ?? 0.0) >= 100.0) {
1195
+ return true;
1196
+ }
1197
+ return false;
252
1198
  }
253
1199
 
254
- private function validateConfig(array $config): void
255
- {
256
- if (empty($config)) {
257
- $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
258
- return;
259
- }
260
-
261
- $knownKeys = [
262
- 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
263
- 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
264
- 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
265
- 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
266
- 'similarityThreshold', 'summary', 'description',
267
- 'ed25519_private_key', 'ed25519_public_key',
268
- 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset',
269
- 'filterWhitelist',
270
- 'differentialPrivacy', 'dpEpsilon'
271
- ];
272
-
273
- if (empty($config['weights'])) {
274
- $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
275
- }
276
- if (empty($config['thresholds'])) {
277
- $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
278
- }
279
-
280
- foreach (array_keys($config) as $key) {
281
- if (!in_array($key, $knownKeys, true)) {
282
- $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
283
- }
284
- }
285
- }
286
- private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
287
- {
288
- $configDir = dirname(__DIR__, 2) . '/config';
289
- $filePath = $configDir . '/' . $filename;
290
- if (file_exists($filePath)) {
291
- try {
292
- $content = file_get_contents($filePath);
293
- if ($content !== false) {
294
- $decoded = json_decode($content, true);
295
- if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
296
- return $decoded;
297
- }
298
- }
299
- } catch (\Throwable $e) {
300
- self::logError("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
301
- }
302
- }
303
- return $fallbackEntries;
304
- }
305
-
306
- public static function googlebot_whitelist(): array
307
- {
308
- if (self::$googlebotEntries === null) {
309
- self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
310
- "2001:4860:4801:10::/64",
311
- "2001:4860:4801:11::/64",
312
- "2001:4860:4801:12::/64",
313
- "66.249.79.64"
314
- ]);
315
- }
316
- return [
317
- 'type' => 'allowlist',
318
- 'entries' => self::$googlebotEntries
319
- ];
320
- }
321
-
322
- public static function yandex_whitelist(): array
323
- {
324
- if (self::$yandexEntries === null) {
325
- self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
326
- "2a02:6b8::/29",
327
- "5.45.192.0/18",
328
- "213.180.192.0/19"
329
- ]);
330
- }
331
- return [
332
- 'type' => 'allowlist',
333
- 'entries' => self::$yandexEntries
334
- ];
335
- }
336
-
337
- public static function bingbot_whitelist(): array
338
- {
339
- if (self::$bingbotEntries === null) {
340
- self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
341
- "157.55.39.0/24",
342
- "207.46.13.0/24",
343
- "40.77.178.0/23"
344
- ]);
345
- }
346
- return [
347
- 'type' => 'allowlist',
348
- 'entries' => self::$bingbotEntries
349
- ];
350
- }
351
-
352
- public static function facebook_whitelist(): array
353
- {
354
- if (self::$facebookEntries === null) {
355
- self::$facebookEntries = self::loadBotWhitelist('facebook.json', [
356
- "31.13.64.0/18", "66.220.144.0/20", "69.63.176.0/20", "157.240.0.0/16"
357
- ]);
358
- }
359
- return [
360
- 'type' => 'allowlist',
361
- 'entries' => self::$facebookEntries
362
- ];
363
- }
364
-
365
- public static function default_whitelist(): array
366
- {
367
- return [
368
- self::googlebot_whitelist(),
369
- self::bingbot_whitelist(),
370
- self::yandex_whitelist(),
371
- self::facebook_whitelist(),
372
- ['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
373
- ['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
374
- ['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
375
- ['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
376
- ['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
377
- ['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
378
- ['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
379
- ['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
380
- ['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
381
- ['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
382
- ['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
383
- ['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
384
- ['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
385
- ['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
386
- ['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
387
- ['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
388
- ['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
389
- ['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
390
- ['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
391
- ['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
392
- ['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
393
- ['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
394
- ['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
395
- ];
396
- }
397
- private function log(string $message, array $data = [], string $level = 'info'): void
398
- {
399
- if ($this->verbose) {
400
- // Utilise le logger s'il est configuré, sinon error_log
401
- if ($this->logger) {
402
- $this->logger->log($level, "[FingerprintEngine] " . $message, $data);
403
- } else {
404
- $logMessage = "[FingerprintEngine] {$message}";
405
- if (!empty($data)) $logMessage .= ' ' . json_encode($data);
406
- self::logError($logMessage);
407
- }
408
- }
409
- }
410
-
411
- public function calculateFinalScore(array $suspicionVector): float
412
- {
413
- $baseWeights = $this->securityConfig['weights'] ?? [];
414
-
415
- $score = 0.0;
416
- // NOUVEAU: Logique d'amplification des poids
417
- // Si un tunnel est détecté, on augmente le poids des autres signaux suspects.
418
- $dynamicWeights = $baseWeights;
419
- if (($suspicionVector['mtuAnomalyScore'] ?? 0.0) > 50.0) {
420
- $this->log('Tunnel detected, amplifying suspicion weights.', ['mtuScore' => $suspicionVector['mtuAnomalyScore']]);
421
-
422
- // Augmente le poids des incohérences de bas niveau (difficiles à falsifier)
423
- $dynamicWeights['tlsSpoofingScore'] = ($baseWeights['tlsSpoofingScore'] ?? 0.8) * 1.25;
424
- $dynamicWeights['crossLayerInconsistencyScore'] = ($baseWeights['crossLayerInconsistencyScore'] ?? 0.4) * 1.4;
425
- $dynamicWeights['clientHintsInconsistencyScore'] = ($baseWeights['clientHintsInconsistencyScore'] ?? 0.7) * 1.2;
426
-
427
- // Augmente le poids des anomalies comportementales (un bot derrière un VPN est plus suspect)
428
- $dynamicWeights['behaviorScore'] = ($baseWeights['behaviorScore'] ?? 0.7) * 1.15;
429
- $dynamicWeights['requestPatternScore'] = ($baseWeights['requestPatternScore'] ?? 0.6) * 1.2;
430
- }
431
-
432
- foreach ($dynamicWeights as $key => $weight) {
433
- $metricScore = $suspicionVector[$key] ?? 0.0;
434
- if ($metricScore > 0) {
435
- $score += $metricScore * $weight;
436
- }
437
- }
438
-
439
- return min(100.0, $score);
440
- }
441
-
442
- private function buildAllowlist(): BlockList
443
- {
444
- $blockList = new BlockList();
445
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
446
-
447
- $allowlistRule = null;
448
- foreach ($whitelistRules as $rule) {
449
- if (($rule['type'] ?? '') === 'allowlist') {
450
- $allowlistRule = $rule;
451
- break;
452
- }
453
- }
454
-
455
- if (empty($allowlistRule['entries'])) {
456
- return $blockList;
457
- }
458
-
459
- foreach ($allowlistRule['entries'] as $entry) {
460
- $blockList->add($entry);
461
- }
462
- return $blockList;
463
- }
464
-
465
- private function isIpInAllowlist(string $clientIp): bool
466
- {
467
- return $this->allowlist->check($clientIp);
468
- }
469
-
470
- private function isPathInAllowlist(string $requestPath): bool
471
- {
472
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
473
- $pathAllowlistRule = null;
474
- foreach ($whitelistRules as $rule) {
475
- if (($rule['type'] ?? '') === 'path_allowlist') {
476
- $pathAllowlistRule = $rule;
477
- break;
478
- }
479
- }
480
-
481
- if (empty($pathAllowlistRule['entries'])) {
482
- return false;
483
- }
484
-
485
- foreach ($pathAllowlistRule['entries'] as $entry) {
486
- if (str_ends_with($entry, '*')) {
487
- $base = substr($entry, 0, -1);
488
- if (str_starts_with($requestPath, $base)) {
489
- return true;
490
- }
491
- } elseif ($requestPath === $entry) {
492
- return true;
493
- }
494
- }
495
-
496
- return false;
497
- }
498
-
499
- private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
500
- {
501
- if (empty($requestHost)) {
502
- return false;
503
- }
504
-
505
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
506
- $hostPathRule = null;
507
- foreach ($whitelistRules as $rule) {
508
- if (($rule['type'] ?? '') === 'host_path_allowlist') {
509
- $hostPathRule = $rule;
510
- break;
511
- }
512
- }
513
-
514
- if (empty($hostPathRule['entries'])) {
515
- return false;
516
- }
517
-
518
- foreach ($hostPathRule['entries'] as $entry) {
519
- if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
520
- return true;
521
- }
522
- }
523
- return false;
524
- }
525
-
526
- /**
527
- * Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
528
- */
529
- private function checkAllowlists(RequestContext $context): bool
530
- {
531
- $whitelisted = false;
532
- $type = '';
533
-
534
- if ($this->isIpInAllowlist($context->clientIp)) {
535
- $whitelisted = true;
536
- $type = 'allowlist';
537
- } elseif ($this->isPathInAllowlist($context->path)) {
538
- $whitelisted = true;
539
- $type = 'path_allowlist';
540
- } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
541
- $whitelisted = true;
542
- $type = 'host_path_allowlist';
543
- } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
544
- $whitelisted = true;
545
- $type = 'graphql_operation_allowlist';
546
- } elseif ($this->verifyWhitelistedBot($context)) {
547
- $whitelisted = true;
548
- $type = 'bot';
549
- }
550
-
551
- if ($whitelisted) {
552
- $filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
553
- $bypassWhitelist = false;
554
- if ($filterWhitelist === true || (is_numeric($filterWhitelist) && $type === 'bot')) {
555
- $bypassWhitelist = $this->hasCertainAttack($context);
556
- } elseif (is_numeric($filterWhitelist)) {
557
- if ($context->preCalculatedScore === null) {
558
- $suspicionVector = [];
559
- $context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
560
- $context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
561
- }
562
- if ($context->preCalculatedScore > $filterWhitelist) {
563
- $bypassWhitelist = true;
564
- }
565
- }
566
-
567
- if ($bypassWhitelist) {
568
- $this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
569
- return false;
570
- }
571
- $this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
572
- return true;
573
- }
574
-
575
- return false;
576
- }
577
-
578
- /**
579
- * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
580
- */
581
- private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
582
- {
583
- if ($context->resolvedIdentity !== null) {
584
- return $context->resolvedIdentity;
585
- }
586
- $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
587
- $store = StoreManager::getStore();
588
- $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
589
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
590
- $pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
591
-
592
- $deviceId = $existingDeviceId;
593
- $deviceData = null;
594
- $newCookie = null;
595
-
596
- // Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
597
- $tlsSessionId = $context->tlsSessionId;
598
- if (!$deviceId && $tlsSessionId) {
599
- $resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
600
- if ($resumedDeviceId) {
601
- $deviceId = $resumedDeviceId;
602
- $this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
603
- }
604
- }
605
-
606
- if ($deviceId) {
607
- $deviceData = $store->get("device:{$deviceId}");
608
- }
609
-
610
- if ($deviceData === null) {
611
- // Nouvel utilisateur ou cookie perdu/invalide
612
- $pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
613
- if ($pendingDeviceId && !$existingDeviceId) {
614
- // La pénalité est maintenant ajoutée directement au vecteur de suspicion.
615
- $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
616
- $suspicionVector['cookieDroppingScore'] = 100.0;
617
- }
618
-
619
- $deviceId = bin2hex(random_bytes(16)); // UUID-like
620
-
621
- $isHttps = !empty($context->isHttps);
622
- $secureOption = $isHttps;
623
-
624
- // Préparer le cookie à envoyer
625
- $newCookie = [
626
- 'name' => 'device_id',
627
- 'value' => $deviceId,
628
- 'options' => [
629
- 'httponly' => true,
630
- 'secure' => $secureOption,
631
- 'samesite' => 'Strict',
632
- 'path' => '/',
633
- ]
634
- ];
635
- if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
636
- $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
637
- }
638
-
639
- $store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
640
-
641
- $deviceData = [
642
- 'initialDeviceHash' => $currentDeviceHash,
643
- 'ips' => [$context->clientIp],
644
- 'requestHistory' => [],
645
- 'lastUpdate' => time() * 1000,
646
- 'lastFpHash' => $currentDeviceHash,
647
- 'lastChangeTimestamp' => 0,
648
- 'rapidChangeCount' => 0,
649
- 'highScoreCount' => 0,
650
- 'lastHighScoreTimestamp' => 0,
651
- ];
652
- } else {
653
- // S'assurer que 'ips' est un tableau pour les opérations suivantes.
654
- if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
655
- $deviceData['ips'] = [];
656
- }
657
- }
658
-
659
- // Validation de l'ancre matérielle WebAuthn (Secure Enclave / TPM)
660
- $behaviorHeader = $context->getHeader('x-behavior-metrics');
661
- if (!empty($behaviorHeader) && is_string($behaviorHeader) && str_starts_with($behaviorHeader, '{')) {
662
- try {
663
- $parsedMetrics = json_decode($behaviorHeader, true);
664
- if (is_array($parsedMetrics) && !empty($parsedMetrics['webauthnAnchor']) && is_array($deviceData)) {
665
- if ($this->verifyWebAuthnHardwareAnchor($parsedMetrics['webauthnAnchor'], $deviceData)) {
666
- $deviceData['webauthnVerified'] = true;
667
- $store->set("device:{$deviceId}", $deviceData);
668
- }
669
- }
670
- } catch (\Throwable $e) {
671
- }
672
- }
673
-
674
- // Bind the current TLS session ID to the device ID
675
- if ($deviceId && $tlsSessionId) {
676
- $store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
677
- }
678
-
679
- $context->resolvedIdentity = [
680
- 'deviceId' => $deviceId,
681
- 'deviceData' => $deviceData,
682
- 'newCookie' => $newCookie,
683
- ];
684
- return $context->resolvedIdentity;
685
- }
686
-
687
- /**
688
- * @return array<string, float>
689
- */
690
- public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
691
- {
692
- $store = StoreManager::getStore();
693
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
694
- $deviceData = $identity['deviceData'];
695
- $deviceId = $identity['deviceId'];
696
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
697
- $suspicionVector['honeypotScore'] = 100;
698
- return $suspicionVector;
699
- }
700
-
701
- // Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
702
- // pour que le code appelant puisse le gérer.
703
- if ($identity['newCookie']) {
704
- // Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
705
- $context->newCookieForResponse = $identity['newCookie'];
706
- }
707
-
708
- // Nettoyage périodique des données de l'appareil
709
- if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
710
- $deviceData['ips'] = [];
711
- $deviceData['rapidChangeCount'] = 0;
712
- $deviceData['ipTimes'] = [];
713
- }
714
- $deviceData['lastUpdate'] = time() * 1000;
715
-
716
- // --- Calcul des différents scores de suspicion ---
717
-
718
- // Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
719
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
720
- $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
721
- $similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? 0.72);
722
- $inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
723
-
724
- $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
725
-
726
- // Score des anomalies d'en-têtes
727
- $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
728
-
729
- // Score de spoofing TLS
730
- $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
731
- $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
732
- $virtualizationScore = RequestUtils::getVirtualizationAnomalyScore($context);
733
-
734
- // Advanced JA4 TLS Inconsistency checks
735
- $ja4 = $context->getHeader('x-ja4-hash');
736
- if ($ja4) {
737
- $spoofedJa4s = [
738
- 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
739
- 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
740
- 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
741
- 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
742
- 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
743
- 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
744
- ];
745
- if (in_array($ja4, $spoofedJa4s, true)) {
746
- $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
747
- }
748
-
749
- $parsedJa4 = $this->parseJa4($ja4);
750
- if ($parsedJa4) {
751
- $ua = $context->getHeader('user-agent') ?? '';
752
- $uaParts = $this->parseUserAgent($ua);
753
-
754
- // Check 1: Incohérence ALPN / HTTP Version
755
- $httpVersion = $context->httpVersion ?? '1.1';
756
- if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
757
- $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
758
- if (!$hasProxy) {
759
- $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
760
- }
761
- }
762
-
763
- // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
764
- if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
765
- $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
766
- }
767
-
768
- // Check 3: Incohérence User-Agent vs Signature JA4
769
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
770
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
771
- }
772
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
773
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
774
- }
775
-
776
- // Check 4: La stagnation (Lack of Entropy / Genericity)
777
- if (is_string($uaParts['browser'])) {
778
- $ja4Key = "ja4-browsers:{$ja4}";
779
- $seenBrowsers = $store->get($ja4Key) ?: [];
780
- if (!is_array($seenBrowsers)) {
781
- $seenBrowsers = [];
782
- }
783
- $browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
784
- if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
785
- $seenBrowsers[] = $browserFamily;
786
- $store->set($ja4Key, $seenBrowsers, 86400);
787
- }
788
- if (count($seenBrowsers) > 1) {
789
- $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
790
- }
791
- }
792
- }
793
- }
794
-
795
- // Score d'incohérence temporelle (attaque par rejeu)
796
- $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
797
-
798
- // Score des incohérences entre couches (client vs serveur)
799
- $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
800
-
801
- // Score des patterns de requêtes (scraping, vélocité)
802
- $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
803
-
804
- // Score des honeypots
805
- $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
806
-
807
- // Score des métriques comportementales client (souris, clavier)
808
- $behavior = RequestUtils::getBehaviorScore($context);
809
-
810
- // Score de détection de bot explicite (marqueurs d'automatisation)
811
- $bot = RequestUtils::getBotScore($context);
812
-
813
- // Score de variance des clics
814
- $clickVariance = RequestUtils::getClickVarianceScore($context);
815
-
816
- // Score basé sur les listes de menaces (Threat Intelligence)
817
- $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
818
-
819
- // Score d'incohérence des Client-Hints
820
- $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
821
-
822
- // Score de similarité globale de l'empreinte (Clustering Botnet)
823
- $stableFp = RequestUtils::extractStablePart($currentDeviceHash);
824
- $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
825
- $botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
826
-
827
- // NOUVEAU: Score de réputation du sous-réseau IP
828
- $subnetScore = RequestUtils::getSubnetScore($context, $deviceId, $this->securityConfig);
829
-
830
- // Score d'anomalie de pile TCP/IP
831
- $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
832
-
833
- // Score d'anomalie de protocole (HTTP/2 et QUIC)
834
- $protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
835
-
836
- // Score d'anomalie de rendu d'affichage (V-Sync)
837
- $renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
838
-
839
- // Assemblage du vecteur de suspicion final
840
- $suspicionVector = array_merge($suspicionVector, [
841
- 'inconsistencyScore' => $inconsistencyScore,
842
- 'historyScore' => $behavioral['historyScore'],
843
- 'rotationScore' => $behavioral['rotationScore'],
844
- 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
845
- 'tlsSpoofingScore' => $tlsSpoofingScore,
846
- 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
847
- 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
848
- 'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
849
- 'honeypotScore' => $honeypot['honeypotScore'],
850
- 'behaviorScore' => $behavior['behaviorScore'],
851
- 'botScore' => $bot['botScore'],
852
- 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
853
- 'threatIntelScore' => $threatIntel['threatIntelScore'],
854
- 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
855
- 'subnetScore' => $subnetScore['subnetScore'],
856
- 'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
857
- 'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
858
- 'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
859
- 'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
860
- 'virtualizationScore' => $virtualizationScore
861
- ]);
862
-
863
- // Sauvegarder l'état mis à jour de l'appareil dans le store
864
- $store->set("device:{$deviceId}", $deviceData);
865
-
866
- return $suspicionVector;
867
- }
868
-
869
- /**
870
- * Parse a basic User-Agent string.
871
- */
872
- private function parseUserAgent(string $ua): array
873
- {
874
- $result = ['browser' => null, 'os' => null];
875
-
876
- if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
877
- $result['browser'] = 'Chrome';
878
- if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
879
- $result['browser'] .= '/' . $matches[1];
880
- }
881
- } elseif (str_contains($ua, 'Firefox')) {
882
- $result['browser'] = 'Firefox';
883
- if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
884
- $result['browser'] .= '/' . $matches[1];
885
- }
886
- } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
887
- $result['browser'] = 'Safari';
888
- if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
889
- $result['browser'] .= '/' . $matches[1];
890
- }
891
- } elseif (str_contains($ua, 'Edg')) {
892
- $result['browser'] = 'Edge';
893
- if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
894
- $result['browser'] .= '/' . $matches[1];
895
- }
896
- }
897
-
898
- if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
899
- elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
900
- elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
901
- elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
902
- elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
903
- elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
904
-
905
- return $result;
906
- }
907
-
908
- /**
909
- * Parse JA4 string into protocol, version, ALPN etc.
910
- */
911
- private function parseJa4(?string $ja4): ?array
912
- {
913
- if (empty($ja4)) return null;
914
- $parts = explode('_', $ja4);
915
- $ja4a = $parts[0];
916
- if (strlen($ja4a) < 10) return null;
917
- return [
918
- 'protocol' => $ja4a[0],
919
- 'version' => substr($ja4a, 1, 2),
920
- 'sni' => $ja4a[3],
921
- 'ciphersCount' => (int)substr($ja4a, 4, 2),
922
- 'extensionsCount' => (int)substr($ja4a, 6, 2),
923
- 'alpn' => substr($ja4a, 8, 2),
924
- 'ja4b' => $parts[1] ?? null,
925
- 'ja4c' => $parts[2] ?? null
926
- ];
927
- }
928
-
929
-
930
- private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
931
- {
932
- if (empty($mapping['keys'])) {
933
- return $fpString;
934
- }
935
- $reverseKeys = array_flip($mapping['keys']);
936
- $parts = explode('|', $fpString);
937
- $mappedParts = [];
938
- foreach ($parts as $part) {
939
- $pair = explode(':', $part, 2);
940
- if (count($pair) === 2) {
941
- $origKey = $reverseKeys[$pair[0]] ?? $pair[0];
942
- $mappedParts[] = "{$origKey}:{$pair[1]}";
943
- } else {
944
- $mappedParts[] = $part;
945
- }
946
- }
947
- return implode('|', $mappedParts);
948
- }
949
- private function translatePolymorphicHeaders(RequestContext $context): void
950
- {
951
- $store = StoreManager::getStore();
952
- $activeMappings = $store->get('active-polymorphic-mappings') ?: [];
953
- if (!is_array($activeMappings)) {
954
- return;
955
- }
956
-
957
- foreach ($activeMappings as $mapping) {
958
- $devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
959
- $behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
960
-
961
- if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
962
- $context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
963
- if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
964
- $context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
965
- }
966
-
967
- $clientFp = $context->headers['x-device-fingerprint'];
968
- if ($clientFp && is_string($clientFp)) {
969
- $context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
970
- }
971
- break;
972
- }
973
- }
974
- }
975
-
976
- /**
977
- * Évalue si la requête présente des caractéristiques d'attaque flagrantes ou a déclenché un honeypot.
978
- */
979
- private function hasCertainAttack(RequestContext $context): bool
980
- {
981
- $honeypotConfig = $this->securityConfig['honeypot'] ?? [];
982
- $honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
983
- if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
984
- return true;
985
- }
986
- $bot = RequestUtils::getBotScore($context);
987
- if (($bot['botScore'] ?? 0.0) >= 100.0) {
988
- return true;
989
- }
990
- return false;
991
- }
992
-
993
- /**
994
- * Traite une requête entrante et retourne une décision.
995
- * @param RequestContext $context Le contexte de la requête.
996
- * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
997
- */
998
- public function processRequest(RequestContext $context): array
999
- {
1200
+ /**
1201
+ * Processes an incoming request and returns a decision (action to take).
1202
+ * This is the main entry point of the engine: it resolves identity,
1203
+ * checks allowlists/tickets, computes the suspicion score, verifies PoW
1204
+ * submissions, selects and issues challenges, and applies block/allow logic.
1205
+ *
1206
+ * @param RequestContext $context The request context.
1207
+ * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
1208
+ */
1209
+ public function processRequest(RequestContext $context): array
1210
+ {
1000
1211
  $store = StoreManager::getStore();
1001
1212
  $this->translatePolymorphicHeaders($context);
1002
1213
 
1003
- // Initialiser le vecteur de suspicion pour éviter les erreurs de type.
1004
- $suspicionVector = [];
1005
-
1006
- $coopOp = $context->query['coop_op'] ?? null;
1007
- if ($coopOp) {
1008
- $result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
1009
- return [
1010
- 'action' => 'challenge',
1011
- 'status' => 200,
1012
- 'body' => $result
1013
- ];
1014
- }
1214
+ // Initialize the suspicion vector to avoid type errors.
1215
+ $suspicionVector = [];
1216
+
1217
+ $coopOp = $context->query['coop_op'] ?? null;
1218
+ if ($coopOp) {
1219
+ $result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
1220
+ return [
1221
+ 'action' => 'challenge',
1222
+ 'status' => 200,
1223
+ 'body' => $result
1224
+ ];
1225
+ }
1015
1226
 
1016
1227
  $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
1017
-
1228
+
1018
1229
  // Parse GraphQL query if applicable
1019
1230
  if ($context->path === '/graphql' && !empty($context->body)) {
1020
1231
  $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
@@ -1023,877 +1234,949 @@
1023
1234
  }
1024
1235
  }
1025
1236
 
1026
- // 0. Résoudre l'identité de l'appareil et vérifier l'attestation matérielle WebAuthn
1027
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1028
- $deviceId = $identity['deviceId'];
1029
- $deviceData = $identity['deviceData'];
1030
-
1031
- // Règle Anti-Ferme : Un appareil condamné ou menant une attaque certaine ne bénéficie d'aucun bypass
1032
- if (($deviceData['condemned'] ?? false) || $this->hasCertainAttack($context)) {
1033
- if (is_array($deviceData)) {
1034
- $deviceData['condemned'] = true;
1035
- $deviceData['webauthnVerified'] = false;
1036
- $store->set("device:{$deviceId}", $deviceData);
1037
- }
1038
- $this->log('Condemned device or attack detected - revoking hardware trust and blocking', ['deviceId' => $deviceId]);
1039
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100.0, 'vector' => ['honeypotScore' => 100.0]];
1040
- if ($this->dryRun) {
1041
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1042
- $decision['intendedAction'] = $decision['action'];
1043
- $decision['action'] = 'next';
1044
- unset($decision['status'], $decision['body']);
1045
- }
1046
- return $decision;
1047
- }
1048
-
1049
- // Bypass instantané si l'appareil a prouvé cryptographiquement son identité matérielle (Secure Enclave / TPM)
1050
- if ($deviceData && !empty($deviceData['webauthnVerified'])) {
1051
- $this->log('Hardware-anchored device verified (WebAuthn) - full bypass granted', ['deviceId' => $deviceId]);
1052
- $greenlistTtl = 365 * 86400 * 1000; // 1 an (permanent)
1053
- $greenlistTicket = ChallengeUtils::generateStatelessTicket([
1054
- 'expiry' => (int)floor(microtime(true) * 1000) + $greenlistTtl,
1055
- 'originalIp' => $context->clientIp,
1056
- 'deviceId' => $deviceId,
1057
- 'deviceHash' => 'webauthn:greenlist:' . ($deviceData['webauthnCredentialId'] ?? $deviceId),
1058
- 'greenlist' => true
1059
- ]);
1060
-
1061
- $isHttps = !empty($context->isHttps);
1062
- $decision = [
1063
- 'action' => 'next',
1064
- 'score' => 0.0,
1065
- 'vector' => ['webauthn_verified' => 100.0],
1066
- 'cookie' => [
1067
- 'name' => 'pow_clearance',
1068
- 'value' => $greenlistTicket,
1069
- 'options' => [
1070
- 'httponly' => true,
1071
- 'secure' => $isHttps,
1072
- 'samesite' => 'Strict',
1073
- 'expires' => time() + ($greenlistTtl / 1000),
1074
- 'path' => '/',
1075
- ]
1076
- ]
1077
- ];
1078
- if (isset($context->newCookieForResponse)) {
1079
- $decision['newCookieForResponse'] = $context->newCookieForResponse;
1080
- }
1081
- return $decision;
1082
- }
1083
-
1084
- // 1. Vérifier les listes blanches
1085
- if ($this->checkAllowlists($context)) {
1086
- MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
1087
- return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
1088
- }
1089
-
1090
- // Initialiser le vecteur de suspicion
1091
- $thresholds = $this->securityConfig['thresholds'];
1092
-
1093
- // 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
1094
- $powNonce = $context->query['pow_nonce'] ?? null;
1095
- $isChallengeSubmission = $powNonce && (
1096
- isset($context->query['pow_solution']) ||
1097
- isset($context->query['pow_solution_cpu']) ||
1098
- isset($context->query['pow_solution_space']) ||
1099
- (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
1100
- );
1101
- if ($isChallengeSubmission) {
1102
- $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
1103
- $store = StoreManager::getStore();
1104
- $challengeContext = $store->get("secret:{$powNonce}");
1105
- $powType = $context->query['pow_type'] ?? null;
1106
-
1107
- if ($challengeContext) {
1108
- $isValid = false;
1109
- $ticket = null;
1110
-
1111
- // Vérification de la cohérence du fingerprint
1112
- $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
1113
- $originalFingerprint = $challengeContext['fingerprint'] ?? '';
1114
- $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
1115
- $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
1116
-
1117
- if ($similarity < $similarityThreshold) {
1118
- $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
1119
- 'similarity' => round($similarity, 4),
1120
- 'threshold' => $similarityThreshold
1121
- ], 'warn');
1122
- $isValid = false;
1123
- } else {
1124
- // Le fingerprint est cohérent, on peut valider la solution
1125
- if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
1126
- $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
1127
- if ($cpuSolution !== null && $cpuSolution !== '') {
1128
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1129
- $deviceId = (string)($identity['deviceId'] ?? '');
1130
- $currentDeviceHash = (string)($identity['currentDeviceHash'] ?? RequestUtils::getCompositeDeviceHash($context));
1131
-
1132
- $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket(
1133
- $context->clientIp,
1134
- 3600000,
1135
- $powNonce,
1136
- (string)$cpuSolution,
1137
- $challengeContext,
1138
- $deviceId,
1139
- $currentDeviceHash
1140
- );
1141
- $isValid = $ticket !== null;
1142
-
1143
- if ($powType === 'cpu_mem') {
1144
- if (!empty($challengeContext['isHttp'])) {
1145
- $isMemValid = true;
1146
- } else {
1147
- $memSolution = $context->query['pow_solution_mem'] ?? null;
1148
- $isMemValid = ($memSolution !== null && $memSolution !== '')
1149
- ? ChallengeUtils::verifyMemoryPoW(
1150
- $powNonce,
1151
- (string)$memSolution,
1152
- $challengeContext['memDifficulty'] ?? 0,
1153
- $challengeContext['clientSecret'] ?? ''
1154
- )
1155
- : false;
1156
- }
1157
- $isValid = $isValid && $isMemValid;
1158
- }
1159
- }
1160
- } elseif ($powType === 'pospace') {
1161
- $powSolutionSpace = $context->query['pow_solution_space'] ?? null;
1162
- if ($powSolutionSpace && isset($challengeContext['queries'])) {
1163
- $isSpaceValid = ChallengeUtils::verifySpacePoW(
1164
- $powNonce,
1165
- $powSolutionSpace,
1166
- $challengeContext['queries'],
1167
- $powNonce . ":" . $challengeContext['clientSecret'],
1168
- $challengeContext['clientSecret']
1169
- );
1170
- $isValid = $isSpaceValid;
1171
- if ($isValid) {
1172
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1173
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1174
- $ticket = ChallengeUtils::generateStatelessTicket([
1175
- 'expiry' => $expiry,
1176
- 'originalIp' => $context->clientIp,
1177
- 'deviceId' => '',
1178
- 'deviceHash' => ''
1179
- ]);
1180
- }
1181
- }
1182
- } elseif ($powType === 'useful_work_task') {
1183
- $problemId = $context->query['pow_problem_id'] ?? null;
1184
- $workResultJson = $context->query['pow_solution_work_result'] ?? null;
1185
- if ($problemId && $workResultJson) {
1186
- $workResult = json_decode($workResultJson, true);
1187
- $this->log('Verifying useful work solution.', [
1188
- 'problemId' => $problemId,
1189
- 'receivedData' => $workResult,
1190
- 'jsonLastError' => json_last_error_msg()
1191
- ]);
1192
- if (json_last_error() === JSON_ERROR_NONE) {
1193
- // @phpstan-ignore-next-line - L'instance est gérée par le singleton
1194
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1237
+ // 0. Resolve device identity and validate WebAuthn hardware attestation
1238
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1239
+ $deviceId = $identity['deviceId'];
1240
+ $deviceData = $identity['deviceData'];
1241
+
1242
+ // Anti-Farm Rule: A condemned device or one conducting a certain attack gets no bypass
1243
+ if (($deviceData['condemned'] ?? false) || $this->hasCertainAttack($context)) {
1244
+ if (is_array($deviceData)) {
1245
+ $deviceData['condemned'] = true;
1246
+ $deviceData['webauthnVerified'] = false;
1247
+ $store->set("device:{$deviceId}", $deviceData);
1248
+ }
1249
+ $this->log('Condemned device or attack detected - revoking hardware trust and blocking', ['deviceId' => $deviceId]);
1250
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100.0, 'vector' => ['honeypotScore' => 100.0]];
1251
+ if ($this->dryRun) {
1252
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1253
+ $decision['intendedAction'] = $decision['action'];
1254
+ $decision['action'] = 'next';
1255
+ unset($decision['status'], $decision['body']);
1256
+ }
1257
+ return $decision;
1258
+ }
1259
+
1260
+ // Instant bypass if the device has cryptographically proven its hardware identity (Secure Enclave / TPM)
1261
+ if ($deviceData && !empty($deviceData['webauthnVerified'])) {
1262
+ $this->log('Hardware-anchored device verified (WebAuthn) - full bypass granted', ['deviceId' => $deviceId]);
1263
+ $greenlistTtl = 365 * 86400 * 1000; // 1 year (permanent)
1264
+ $greenlistTicket = ChallengeUtils::generateStatelessTicket([
1265
+ 'expiry' => (int)floor(microtime(true) * 1000) + $greenlistTtl,
1266
+ 'originalIp' => $context->clientIp,
1267
+ 'deviceId' => $deviceId,
1268
+ 'deviceHash' => 'webauthn:greenlist:' . ($deviceData['webauthnCredentialId'] ?? $deviceId),
1269
+ 'greenlist' => true
1270
+ ]);
1271
+
1272
+ $isHttps = !empty($context->isHttps);
1273
+ $decision = [
1274
+ 'action' => 'next',
1275
+ 'score' => 0.0,
1276
+ 'vector' => ['webauthn_verified' => 100.0],
1277
+ 'cookie' => [
1278
+ 'name' => 'pow_clearance',
1279
+ 'value' => $greenlistTicket,
1280
+ 'options' => [
1281
+ 'httponly' => true,
1282
+ 'secure' => $isHttps,
1283
+ 'samesite' => 'Strict',
1284
+ 'expires' => time() + ($greenlistTtl / 1000),
1285
+ 'path' => '/',
1286
+ ]
1287
+ ]
1288
+ ];
1289
+ if (isset($context->newCookieForResponse)) {
1290
+ $decision['newCookieForResponse'] = $context->newCookieForResponse;
1291
+ }
1292
+ return $decision;
1293
+ }
1294
+
1295
+ // --- PRIVATE ACCESS TOKENS (PAT / RFC 9578 & Privacy Pass) ZERO-FRICTION CHALLENGE EXEMPTION ---
1296
+ $rawPatTokens = RequestUtils::extractPrivateAccessTokens($context);
1297
+ $hasValidPat = false;
1298
+ if (!empty($rawPatTokens)) {
1299
+ $patConfig = $this->securityConfig['pat'] ?? [];
1300
+ foreach ($rawPatTokens as $rawToken) {
1301
+ $parsedToken = RequestUtils::parsePrivateAccessToken($rawToken);
1302
+ if ($parsedToken !== null) {
1303
+ $nonceKey = "pat-nonce:{$parsedToken['nonce']}";
1304
+ if (!$store->has($nonceKey)) {
1305
+ $isValid = RequestUtils::verifyPatSignature($parsedToken, $patConfig);
1306
+ if ($isValid) {
1307
+ $nonceTtl = $patConfig['nonceTtl'] ?? 86400;
1308
+ $store->set($nonceKey, true, $nonceTtl);
1309
+ $this->log('Private Access Token (PAT) cryptographically verified - challenge exemption enabled', [
1310
+ 'tokenType' => $parsedToken['tokenType'],
1311
+ 'tokenKeyId' => $parsedToken['tokenKeyId']
1312
+ ]);
1313
+ $hasValidPat = true;
1314
+ break;
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ // 1. Check allowlists
1322
+ if ($this->checkAllowlists($context)) {
1323
+ MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
1324
+ return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
1325
+ }
1326
+
1327
+ // Initialize the suspicion vector
1328
+ $thresholds = $this->securityConfig['thresholds'];
1329
+
1330
+ // 2. Handle challenge solution submission (HIGH PRIORITY)
1331
+ $powNonce = $context->query['pow_nonce'] ?? null;
1332
+ $isChallengeSubmission = $powNonce && (
1333
+ isset($context->query['pow_solution']) ||
1334
+ isset($context->query['pow_solution_cpu']) ||
1335
+ isset($context->query['pow_solution_space']) ||
1336
+ (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
1337
+ );
1338
+ if ($isChallengeSubmission) {
1339
+ $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
1340
+ $store = StoreManager::getStore();
1341
+ $challengeContext = $store->get("secret:{$powNonce}");
1342
+ $powType = $context->query['pow_type'] ?? null;
1343
+
1344
+ if ($challengeContext) {
1345
+ $isValid = false;
1346
+ $ticket = null;
1347
+
1348
+ // Fingerprint consistency check
1349
+ $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
1350
+ $originalFingerprint = $challengeContext['fingerprint'] ?? '';
1351
+ $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
1352
+ $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
1353
+
1354
+ if ($similarity < $similarityThreshold) {
1355
+ $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
1356
+ 'similarity' => round($similarity, 4),
1357
+ 'threshold' => $similarityThreshold
1358
+ ], 'warn');
1359
+ $isValid = false;
1360
+ } else {
1361
+ // The fingerprint is consistent, the solution can be verified
1362
+ if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
1363
+ $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
1364
+ if ($cpuSolution !== null && $cpuSolution !== '') {
1365
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1366
+ $deviceId = (string)($identity['deviceId'] ?? '');
1367
+ $currentDeviceHash = (string)($identity['currentDeviceHash'] ?? RequestUtils::getCompositeDeviceHash($context));
1368
+
1369
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket(
1370
+ $context->clientIp,
1371
+ 3600000,
1372
+ $powNonce,
1373
+ (string)$cpuSolution,
1374
+ $challengeContext,
1375
+ $deviceId,
1376
+ $currentDeviceHash
1377
+ );
1378
+ $isValid = $ticket !== null;
1379
+
1380
+ if ($powType === 'cpu_mem') {
1381
+ if (!empty($challengeContext['isHttp'])) {
1382
+ $isMemValid = true;
1383
+ } else {
1384
+ $memSolution = $context->query['pow_solution_mem'] ?? null;
1385
+ $isMemValid = $memSolution !== null && $memSolution !== '' && ChallengeUtils::verifyMemoryPoW(
1386
+ $powNonce,
1387
+ (string)$memSolution,
1388
+ $challengeContext['memDifficulty'] ?? 0,
1389
+ $challengeContext['clientSecret'] ?? ''
1390
+ );
1391
+ }
1392
+ $isValid = $isValid && $isMemValid;
1393
+ }
1394
+ }
1395
+ } elseif ($powType === 'pospace') {
1396
+ $powSolutionSpace = $context->query['pow_solution_space'] ?? null;
1397
+ if ($powSolutionSpace && isset($challengeContext['queries'])) {
1398
+ $isSpaceValid = ChallengeUtils::verifySpacePoW(
1399
+ $powNonce,
1400
+ $powSolutionSpace,
1401
+ $challengeContext['queries'],
1402
+ $powNonce . ":" . $challengeContext['clientSecret'],
1403
+ $challengeContext['clientSecret']
1404
+ );
1405
+ $isValid = $isSpaceValid;
1406
+ if ($isValid) {
1407
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1408
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1409
+ $ticket = ChallengeUtils::generateStatelessTicket([
1410
+ 'expiry' => $expiry,
1411
+ 'originalIp' => $context->clientIp,
1412
+ 'deviceId' => '',
1413
+ 'deviceHash' => ''
1414
+ ]);
1415
+ }
1416
+ }
1417
+ } elseif ($powType === 'useful_work_task') {
1418
+ $problemId = $context->query['pow_problem_id'] ?? null;
1419
+ $workResultJson = $context->query['pow_solution_work_result'] ?? null;
1420
+ if ($problemId && $workResultJson) {
1421
+ $workResult = json_decode($workResultJson, true);
1422
+ $this->log('Verifying useful work solution.', [
1423
+ 'problemId' => $problemId,
1424
+ 'receivedData' => $workResult,
1425
+ 'jsonLastError' => json_last_error_msg()
1426
+ ]);
1427
+ if (json_last_error() === JSON_ERROR_NONE) {
1428
+ $defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
1429
+ ? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
1430
+ : (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
1195
1431
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1196
1432
  $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
1197
- // FIX: La solution est directement le $workResult, pas une sous-propriété.
1198
- $problemManager->integrateSolution($problemId, $workResult);
1199
-
1200
- // Si le problème résolu est l'auto-tuning de sécurité et que l'auto-tuning est activé,
1201
- // on applique directement la meilleure solution calculée au moteur en direct.
1202
- if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
1203
- $paretoFront = $workResult['paretoFront'] ?? null;
1204
- if (is_array($paretoFront) && !empty($paretoFront)) {
1205
- $bestSolution = $paretoFront[0];
1206
- $minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
1207
- for ($i = 1; $i < count($paretoFront); $i++) {
1208
- $distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
1209
- if ($distance < $minDistance) {
1210
- $minDistance = $distance;
1211
- $bestSolution = $paretoFront[$i];
1212
- }
1213
- }
1214
- if (isset($bestSolution['solution'])) {
1215
- $this->updateConfig($bestSolution['solution']);
1216
- $this->log('Useful Work auto-tuning applied successfully to live config.');
1217
- }
1218
- }
1219
- }
1220
-
1221
- $isValid = true;
1222
- // FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
1223
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
1224
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1225
- $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
1226
- $ticket = "{$expiry}:{$signature}";
1227
- }
1228
- }
1229
- }
1230
- }
1231
-
1232
- if ($isValid) {
1233
- $store->delete("secret:{$powNonce}");
1234
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1235
- MetricsManager::incrementCounter('challenges_solved_total');
1236
- $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
1237
-
1238
- $isHttps = !empty($context->isHttps);
1239
- $secureOption = $isHttps;
1240
-
1241
- return [
1242
- 'action' => 'redirect',
1243
- 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
1244
- 'score' => 0.0,
1245
- 'vector' => ['challenge_solved' => 100],
1246
- 'cookie' => [
1247
- 'name' => 'pow_clearance',
1248
- 'value' => $ticket,
1249
- 'options' => [
1250
- 'httponly' => true,
1251
- 'secure' => $secureOption,
1252
- 'samesite' => 'Strict',
1253
- 'expires' => time() + ($ticketTtl / 1000),
1254
- 'path' => '/',
1255
- ]
1256
- ]
1257
- ];
1258
- }
1259
- }
1260
- // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
1433
+ // FIX: The solution is directly $workResult, not a sub-property.
1434
+ $problemManager->integrateSolution($problemId, $workResult);
1435
+
1436
+ // If the solved problem is security auto-tuning and auto-tuning is enabled,
1437
+ // we directly apply the best computed solution to the live engine.
1438
+ if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
1439
+ $paretoFront = $workResult['paretoFront'] ?? null;
1440
+ if (is_array($paretoFront) && !empty($paretoFront)) {
1441
+ $bestSolution = $paretoFront[0];
1442
+ $minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
1443
+ for ($i = 1; $i < count($paretoFront); $i++) {
1444
+ $distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
1445
+ if ($distance < $minDistance) {
1446
+ $minDistance = $distance;
1447
+ $bestSolution = $paretoFront[$i];
1448
+ }
1449
+ }
1450
+ if (isset($bestSolution['solution'])) {
1451
+ $this->updateConfig($bestSolution['solution']);
1452
+ $this->log('Useful Work auto-tuning applied successfully to live config.');
1453
+ }
1454
+ }
1455
+ }
1456
+
1457
+ $isValid = true;
1458
+ // FIX: Generate a real ticket for uPoW, like for a normal PoW.
1459
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 hour by default
1460
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1461
+ $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
1462
+ $ticket = "{$expiry}:{$signature}";
1463
+ }
1464
+ }
1465
+ }
1466
+ }
1467
+
1468
+ if ($isValid) {
1469
+ $store->delete("secret:{$powNonce}");
1470
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1471
+ MetricsManager::incrementCounter('challenges_solved_total');
1472
+ $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
1473
+
1474
+ $isHttps = !empty($context->isHttps);
1475
+ $secureOption = $isHttps;
1476
+
1477
+ return [
1478
+ 'action' => 'redirect',
1479
+ 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
1480
+ 'score' => 0.0,
1481
+ 'vector' => ['challenge_solved' => 100],
1482
+ 'cookie' => [
1483
+ 'name' => 'pow_clearance',
1484
+ 'value' => $ticket,
1485
+ 'options' => [
1486
+ 'httponly' => true,
1487
+ 'secure' => $secureOption,
1488
+ 'samesite' => 'Strict',
1489
+ 'expires' => time() + ($ticketTtl / 1000),
1490
+ 'path' => '/',
1491
+ ]
1492
+ ]
1493
+ ];
1494
+ }
1495
+ }
1496
+ // If the solution is invalid or the nonce is expired, we strongly penalize it for the following steps.
1261
1497
  MetricsManager::incrementCounter('challenges_failed_total');
1262
- $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
1263
- $suspicionVector['honeypotScore'] = 100.0;
1264
- }
1265
-
1266
- // 3. Vérifier un ticket existant
1267
- $hasValidTicket = false;
1268
- $powCookie = $context->cookies['pow_clearance'] ?? null;
1269
- $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1270
- $deviceId = $context->cookies['device_id'] ?? '';
1271
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
1272
- $allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
1273
- if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
1274
- $hasValidTicket = true;
1498
+ $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
1499
+ $suspicionVector['honeypotScore'] = 100.0;
1500
+ }
1501
+
1502
+ // 3. Check an existing ticket
1503
+ $hasValidTicket = false;
1504
+ $powCookie = $context->cookies['pow_clearance'] ?? null;
1505
+ $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1506
+ $deviceId = $context->cookies['device_id'] ?? '';
1507
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
1508
+ $allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
1509
+ if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof) || $hasValidPat) {
1510
+ $hasValidTicket = true;
1275
1511
  MetricsManager::incrementCounter('tickets_valid_total');
1276
- // On ne retourne pas tout de suite pour permettre le re-challenge
1277
- // $this->log('Valid clearance ticket found');
1278
- // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
1279
- }
1280
-
1281
- // 4. Calculer le vecteur et le score de suspicion
1282
- // Résoudre l'identité et vérifier le statut "condamné"
1283
- $store = StoreManager::getStore();
1284
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1285
- $deviceId = $identity['deviceId'];
1286
- $deviceData = $identity['deviceData'];
1287
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
1288
- $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
1289
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1290
- if ($this->dryRun) {
1291
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1292
- $decision['intendedAction'] = $decision['action'];
1293
- $decision['action'] = 'next';
1294
- unset($decision['status'], $decision['body']);
1295
- }
1296
- return $decision;
1297
- }
1298
-
1299
- $suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
1300
- $finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
1301
- $this->log('Suspicion vector and final score calculated', [
1302
- 'finalScore' => round($finalScore, 2),
1303
- 'vector' => $suspicionVector
1304
- ]);
1305
-
1306
- // Mettre à jour les métriques du sous-réseau après le calcul du score final
1307
- if ($finalScore >= ($thresholds['medium'] ?? 45)) {
1512
+ // We do not return immediately to allow re-challenging.
1513
+ // $this->log('Valid clearance ticket found');
1514
+ // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
1515
+ }
1516
+
1517
+ // 4. Compute the suspicion vector and score
1518
+ // Resolve identity and check "condemned" status
1519
+ $store = StoreManager::getStore();
1520
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1521
+ $deviceId = $identity['deviceId'];
1522
+ $deviceData = $identity['deviceData'];
1523
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
1524
+ $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
1525
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1526
+ if ($this->dryRun) {
1527
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1528
+ $decision['intendedAction'] = $decision['action'];
1529
+ $decision['action'] = 'next';
1530
+ unset($decision['status'], $decision['body']);
1531
+ }
1532
+ return $decision;
1533
+ }
1534
+
1535
+ $suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
1536
+ if ($hasValidPat) {
1537
+ $suspicionVector['pat_verified'] = 100.0;
1538
+ }
1539
+ $finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
1540
+ $this->log('Suspicion vector and final score calculated', [
1541
+ 'finalScore' => round($finalScore, 2),
1542
+ 'vector' => $suspicionVector
1543
+ ]);
1544
+
1545
+ // Update subnet metrics after computing the final score
1546
+ if ($finalScore >= ($thresholds['medium'] ?? 45)) {
1308
1547
  RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
1309
1548
  MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
1310
- }
1311
-
1312
- // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
1313
- $isNewDevice = $identity['newCookie'] !== null;
1314
- if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
1315
- $this->log('New device - enforcing minimum challenge score', [
1316
- 'originalScore' => round($finalScore, 2),
1317
- 'enforcedScore' => (float)$thresholds['low']
1318
- ]);
1319
- $finalScore = (float)$thresholds['low'];
1320
- }
1321
-
1322
- // Vérifier les URL pièges (après calcul du score)
1323
- $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
1324
- if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
1325
- if ($this->logger) {
1326
- $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
1327
- }
1328
- $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
1329
- $deviceData['condemned'] = true; // @phpstan-ignore-line
1330
- $store->set("device:{$deviceId}", $deviceData);
1331
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1332
- if ($this->dryRun) {
1333
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1334
- $decision['intendedAction'] = $decision['action'];
1335
- $decision['action'] = 'next';
1336
- unset($decision['status'], $decision['body']);
1337
- }
1338
- return $decision;
1339
- }
1340
-
1341
- // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
1342
- $blockThreshold = $thresholds['block'] ?? 95;
1343
- $isBlocked = $finalScore >= $blockThreshold;
1344
- if ($isBlocked) {
1345
- if ($this->logger) {
1549
+ }
1550
+
1551
+ // Logic to challenge new devices (moved here to have the final score)
1552
+ $isNewDevice = $identity['newCookie'] !== null;
1553
+ if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
1554
+ $this->log('New device - enforcing minimum challenge score', [
1555
+ 'originalScore' => round($finalScore, 2),
1556
+ 'enforcedScore' => (float)$thresholds['low']
1557
+ ]);
1558
+ $finalScore = (float)$thresholds['low'];
1559
+ }
1560
+
1561
+ // Check trap URLs (after score computation)
1562
+ $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
1563
+ if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
1564
+ if ($this->logger) {
1565
+ $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
1566
+ }
1567
+ $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
1568
+ $deviceData['condemned'] = true; // @phpstan-ignore-line
1569
+ $store->set("device:{$deviceId}", $deviceData);
1570
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1571
+ if ($this->dryRun) {
1572
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1573
+ $decision['intendedAction'] = $decision['action'];
1574
+ $decision['action'] = 'next';
1575
+ unset($decision['status'], $decision['body']);
1576
+ }
1577
+ return $decision;
1578
+ }
1579
+
1580
+ // 5. Decide based on the score - Check blocking first.
1581
+ $blockThreshold = $thresholds['block'] ?? 95;
1582
+ $isBlocked = $finalScore >= $blockThreshold;
1583
+ if ($isBlocked) {
1584
+ if ($this->logger) {
1346
1585
  MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
1347
- $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1348
- }
1349
-
1350
- // Federated Threat Intelligence & ZKP Synchronization
1351
- $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1352
- $parts = explode(':', $zkpProof);
1353
- if (count($parts) === 3) {
1354
- $zkpY = $parts[0];
1355
- $zkpT = $parts[1];
1356
- $zkpS = $parts[2];
1357
- // 1. Valider cryptographiquement la preuve avant de bannir/diffuser
1358
- if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
1359
- // 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
1360
- $peersKey = "fed-peers:{$zkpY}";
1361
- $reportedPeers = $store->get($peersKey) ?: [];
1362
- if (!is_array($reportedPeers)) {
1363
- $reportedPeers = [];
1364
- }
1365
- if (!in_array('local', $reportedPeers, true)) {
1366
- $reportedPeers[] = 'local';
1367
- $store->set($peersKey, $reportedPeers, 86400 * 30);
1368
- }
1369
-
1370
- $threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
1371
- if (count($reportedPeers) >= $threshold) {
1372
- $store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
1373
- }
1374
- $this->broadcastBannedZkp($zkpY);
1375
- }
1376
- }
1377
-
1378
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1379
- if ($this->dryRun) {
1380
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1381
- $decision['intendedAction'] = $decision['action'];
1382
- $decision['action'] = 'next';
1383
- unset($decision['status'], $decision['body']);
1586
+ $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1587
+ }
1588
+
1589
+ // Federated Threat Intelligence & ZKP Synchronization
1590
+ $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1591
+ $parts = explode(':', $zkpProof);
1592
+ if (count($parts) === 3) {
1593
+ $zkpY = $parts[0];
1594
+ $zkpT = $parts[1];
1595
+ $zkpS = $parts[2];
1596
+ // 1. Cryptographically validate the proof before banning/broadcasting
1597
+ if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
1598
+ // 2. De-duplicate: only broadcast if the key is not already banned
1599
+ $peersKey = "fed-peers:{$zkpY}";
1600
+ $reportedPeers = $store->get($peersKey) ?: [];
1601
+ if (!is_array($reportedPeers)) {
1602
+ $reportedPeers = [];
1603
+ }
1604
+ if (!in_array('local', $reportedPeers, true)) {
1605
+ $reportedPeers[] = 'local';
1606
+ $store->set($peersKey, $reportedPeers, 86400 * 30);
1607
+ }
1608
+
1609
+ $threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
1610
+ if (count($reportedPeers) >= $threshold) {
1611
+ $store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
1612
+ }
1613
+ $this->broadcastBannedZkp($zkpY);
1614
+ }
1615
+ }
1616
+
1617
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1618
+ if ($this->dryRun) {
1619
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1620
+ $decision['intendedAction'] = $decision['action'];
1621
+ $decision['action'] = 'next';
1622
+ unset($decision['status'], $decision['body']);
1384
1623
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
1385
- }
1386
- $response = $decision;
1387
- } else {
1388
- // Logique de re-challenge
1389
- // Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
1390
- if ($powNonce && !$isChallengeSubmission) {
1391
- $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
1392
- $suspicionVector['honeypotScore'] = 100.0;
1393
- $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
1394
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1395
- // Apply dry run logic here as well
1396
- if ($this->dryRun) {
1624
+ }
1625
+ $response = $decision;
1626
+ } else {
1627
+ // Re-challenge logic
1628
+ // If a nonce is present but it is not a valid solution submission, it is a probe.
1629
+ if ($powNonce && !$isChallengeSubmission) {
1630
+ $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
1631
+ $suspicionVector['honeypotScore'] = 100.0;
1632
+ $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
1633
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1634
+ // Apply dry run logic here as well
1635
+ if ($this->dryRun) {
1397
1636
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
1398
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1399
- $decision['intendedAction'] = $decision['action'];
1400
- $decision['action'] = 'next';
1401
- unset($decision['status'], $decision['body']);
1402
- }
1403
- return $decision;
1404
- }
1405
-
1406
- $mediumThreshold = $thresholds['medium'] ?? 45;
1407
- $maxIndicatorsCount = 0;
1408
- foreach ($suspicionVector as $val) {
1409
- if (is_numeric($val) && (float)$val >= 100.0) {
1410
- $maxIndicatorsCount++;
1411
- }
1412
- }
1413
- $mustReChallenge = (($suspicionVector['honeypotScore'] ?? 0.0) >= $mediumThreshold) || ($maxIndicatorsCount >= 1);
1414
-
1415
- $lowThreshold = $thresholds['low'] ?? 20;
1416
- if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
1417
- if ($mustReChallenge) {
1418
- $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
1419
- }
1420
-
1421
- // --- AJOUT: Limiteur de débit (Token Bucket) ---
1422
- $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
1423
- if (!$rateLimitPassed) {
1424
- $this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
1425
- $decision = [
1426
- 'action' => 'block',
1427
- 'status' => 429,
1428
- 'body' => 'Too Many Requests',
1429
- 'score' => $finalScore,
1430
- 'vector' => $suspicionVector
1431
- ];
1432
- if ($this->dryRun) {
1433
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1434
- $decision['intendedAction'] = $decision['action'];
1435
- $decision['action'] = 'next';
1436
- unset($decision['status'], $decision['body']);
1437
- }
1438
- return $decision;
1439
- }
1440
-
1441
- $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
1442
-
1443
- if ($this->dryRun) {
1637
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1638
+ $decision['intendedAction'] = $decision['action'];
1639
+ $decision['action'] = 'next';
1640
+ unset($decision['status'], $decision['body']);
1641
+ }
1642
+ return $decision;
1643
+ }
1644
+
1645
+ $mediumThreshold = $thresholds['medium'] ?? 45;
1646
+ $maxIndicatorsCount = 0;
1647
+ foreach ($suspicionVector as $val) {
1648
+ if (is_numeric($val) && (float)$val >= 100.0) {
1649
+ $maxIndicatorsCount++;
1650
+ }
1651
+ }
1652
+ $mustReChallenge = (($suspicionVector['honeypotScore'] ?? 0.0) >= $mediumThreshold) || ($maxIndicatorsCount >= 1);
1653
+
1654
+ $lowThreshold = $thresholds['low'] ?? 20;
1655
+ if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
1656
+ if ($mustReChallenge) {
1657
+ $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
1658
+ }
1659
+
1660
+ // --- ADDED: Rate limiter (Token Bucket) ---
1661
+ $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
1662
+ if (!$rateLimitPassed) {
1663
+ $this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
1664
+ $decision = [
1665
+ 'action' => 'block',
1666
+ 'status' => 429,
1667
+ 'body' => 'Too Many Requests',
1668
+ 'score' => $finalScore,
1669
+ 'vector' => $suspicionVector
1670
+ ];
1671
+ if ($this->dryRun) {
1672
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1673
+ $decision['intendedAction'] = $decision['action'];
1674
+ $decision['action'] = 'next';
1675
+ unset($decision['status'], $decision['body']);
1676
+ }
1677
+ return $decision;
1678
+ }
1679
+
1680
+ $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
1681
+
1682
+ if ($this->dryRun) {
1444
1683
  MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
1445
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1446
- $decision['intendedAction'] = $decision['action'];
1447
- $decision['action'] = 'next';
1448
- unset($decision['status']);
1449
- return $decision;
1450
- }
1684
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1685
+ $decision['intendedAction'] = $decision['action'];
1686
+ $decision['action'] = 'next';
1687
+ unset($decision['status']);
1688
+ return $decision;
1689
+ }
1451
1690
 
1452
1691
  MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
1453
- $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
1692
+ $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
1454
1693
 
1455
- $nonce = bin2hex(random_bytes(16));
1456
- $clientSecret = bin2hex(random_bytes(16));
1457
- $highThreshold = $thresholds['high'] ?? 75;
1694
+ $nonce = bin2hex(random_bytes(16));
1695
+ $clientSecret = bin2hex(random_bytes(16));
1696
+ $highThreshold = $thresholds['high'] ?? 75;
1458
1697
 
1459
- $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
1460
- $suspicionFactor = max(0, min(1.5, $suspicionFactor));
1698
+ $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
1699
+ $suspicionFactor = max(0, min(1.5, $suspicionFactor));
1461
1700
 
1462
- // --- NOUVELLE LOGIQUE uPoW ---
1463
- $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
1464
- ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
1465
- );
1701
+ // --- NEW uPoW LOGIC ---
1702
+ $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
1703
+ ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
1704
+ );
1466
1705
 
1467
- // Déterminer si c'est une requête API avant de choisir le type de challenge
1468
- $isApiRequest = false;
1469
- if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
1470
- $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
1471
- }
1706
+ // Determine if it is an API request before choosing the challenge type
1707
+ $isApiRequest = false;
1708
+ if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
1709
+ $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
1710
+ }
1472
1711
 
1473
- if ($shouldUseUsefulWork) {
1474
- $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1475
- $defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
1712
+ if ($shouldUseUsefulWork) {
1713
+ $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1714
+ $defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
1715
+ ? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
1716
+ : (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
1476
1717
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1477
1718
  $problemManager = ProblemManager::getInstance($configPath, $store);
1478
- $work = $problemManager->dispatchWork($finalScore);
1479
-
1480
- if ($work !== null) {
1481
- $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
1482
- $challengePayload = [
1483
- 'challenge' => [
1484
- 'type' => 'useful_work_task',
1485
- 'nonce' => $nonce,
1486
- 'clientSecret' => $clientSecret,
1487
- 'usefulWorkTask' => [
1488
- 'problemId' => $work['problemId'],
1489
- 'task' => $work['task']
1490
- ]
1491
- ]
1492
- ];
1493
- if ($isApiRequest) {
1494
- $decision['body'] = $challengePayload;
1495
- return $decision;
1496
- } else {
1497
- $html = '<html><body><script>';
1498
- $html .= 'window.location.href = "' . $context->path . '?pow_type=useful_work_task&pow_nonce=' . $nonce . '&pow_problem_id=' . $work['problemId'] . '&pow_solution_work_result=" + encodeURIComponent(JSON.stringify({"solution": [], "energy": 0}));';
1499
- $html .= '</script></body></html>';
1500
- $decision['body'] = $html;
1501
- return $decision;
1502
- }
1503
- } else {
1504
- // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
1505
- // We log it and fall through to the standard PoW challenge.
1506
- $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
1507
- $shouldUseUsefulWork = false; // Explicitly disable for this request
1508
- }
1509
- }
1510
-
1511
- // --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
1512
-
1513
- if ($this->securityConfig['enableProofOfSpace'] ?? false) {
1514
- $spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
1515
- $store->set("secret:{$nonce}", [
1516
- 'clientSecret' => $clientSecret,
1517
- 'suspicionScore' => $finalScore,
1518
- 'queries' => $spaceChallenge['queries'],
1519
- 'sizeMb' => $spaceChallenge['sizeMb'],
1520
- 'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
1521
- 'originalPath' => $context->path,
1522
- ], $this->securityConfig['challengeTtl'] ?? 300);
1523
-
1524
- if ($deviceData) {
1525
- $deviceData['lastChallengeNonce'] = $nonce;
1526
- $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1527
- }
1528
-
1529
- if ($isApiRequest) {
1530
- $decision['body'] = [
1531
- 'challenge' => [
1532
- 'type' => 'pospace',
1533
- 'nonce' => $nonce,
1534
- 'clientSecret' => $clientSecret,
1535
- 'queries' => $spaceChallenge['queries'],
1536
- 'sizeMb' => $spaceChallenge['sizeMb'],
1537
- ]
1538
- ];
1539
- } else {
1540
- $page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
1541
- $decision['body'] = $page;
1542
- }
1543
- return $decision;
1544
- }
1545
-
1546
- $cpuChallengeDetails = [
1547
- 'type' => 'cpu_target',
1548
- 'nonce' => $nonce,
1549
- 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1550
- 'path' => $context->path,
1551
- ];
1552
-
1553
- // Alignement linéaire parfait du ratio d'effort CPU/Mémoire
1554
- $memActivationFactor = $suspicionFactor;
1555
- $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
1556
-
1557
- $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1558
- $tlsSessionId = $context->tlsSessionId ?? '';
1559
- $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
1560
-
1561
- $isHttps = !empty($context->isHttps);
1562
- $challengeContext = [
1563
- 'clientSecret' => $clientSecret,
1564
- 'cpuTarget' => $cpuChallengeDetails['target'],
1565
- 'suspicionScore' => $finalScore,
1566
- 'fingerprint' => $originalFingerprint,
1567
- 'memDifficulty' => $memDifficulty,
1568
- 'baseBlock' => $baseBlock,
1569
- 'originalPath' => $context->path,
1570
- 'isHttp' => !$isHttps,
1571
- ];
1572
-
1573
- $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1574
-
1575
- // Associer le nonce au device pour la vérification des URL pièges
1576
- if ($deviceData) {
1577
- $deviceData['lastChallengeNonce'] = $nonce;
1578
- $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1579
- }
1580
-
1581
- $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1582
- $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1583
-
1584
- if ($this->logger) {
1585
- $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1586
- }
1587
-
1588
- // Pour les API, retourner un challenge JSON
1589
- if ($isApiRequest) {
1590
- $challengePayload = [
1591
- 'challenge' => [
1592
- 'type' => 'cpu_mem',
1593
- 'nonce' => $nonce,
1594
- 'clientSecret' => $clientSecret,
1595
- 'cpuTarget' => $cpuChallengeDetails['target'],
1596
- 'memDifficulty' => $memDifficulty,
1597
- 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
1598
- ]
1599
- ];
1600
- $decision['body'] = $challengePayload;
1601
- } else {
1602
- // Pour les navigateurs, retourner une page HTML
1603
- $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1604
- $cpuChallengeDetails, $memDifficulty, $clientSecret,
1605
- $this->securityConfig, $trapUrls, $originalFingerprint,
1606
- $context->clientIp, $tlsSessionId, $baseBlock, $isHttps
1607
- );
1608
- $decision['body'] = $pageBody;
1609
- }
1610
- $response = $decision;
1611
- } elseif ($hasValidTicket) {
1612
- // Si on arrive ici avec un ticket valide et un score bas, on autorise
1719
+ $work = $problemManager->dispatchWork($finalScore);
1720
+
1721
+ if ($work !== null) {
1722
+ $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
1723
+ $challengePayload = [
1724
+ 'challenge' => [
1725
+ 'type' => 'useful_work_task',
1726
+ 'nonce' => $nonce,
1727
+ 'clientSecret' => $clientSecret,
1728
+ 'usefulWorkTask' => [
1729
+ 'problemId' => $work['problemId'],
1730
+ 'task' => $work['task']
1731
+ ]
1732
+ ]
1733
+ ];
1734
+ if ($isApiRequest) {
1735
+ $decision['body'] = $challengePayload;
1736
+ return $decision;
1737
+ } else {
1738
+ $html = '<html><body><script>';
1739
+ $html .= 'window.location.href = "' . $context->path . '?pow_type=useful_work_task&pow_nonce=' . $nonce . '&pow_problem_id=' . $work['problemId'] . '&pow_solution_work_result=" + encodeURIComponent(JSON.stringify({"solution": [], "energy": 0}));';
1740
+ $html .= '</script></body></html>';
1741
+ $decision['body'] = $html;
1742
+ return $decision;
1743
+ }
1744
+ } else {
1745
+ // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
1746
+ // We log it and fall through to the standard PoW challenge.
1747
+ $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
1748
+ $shouldUseUsefulWork = false; // Explicitly disable for this request
1749
+ }
1750
+ }
1751
+
1752
+ // --- END OF uPoW LOGIC (the rest is the fallback) ---
1753
+
1754
+ if ($this->securityConfig['enableProofOfSpace'] ?? false) {
1755
+ $spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
1756
+ $store->set("secret:{$nonce}", [
1757
+ 'clientSecret' => $clientSecret,
1758
+ 'suspicionScore' => $finalScore,
1759
+ 'queries' => $spaceChallenge['queries'],
1760
+ 'sizeMb' => $spaceChallenge['sizeMb'],
1761
+ 'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
1762
+ 'originalPath' => $context->path,
1763
+ ], $this->securityConfig['challengeTtl'] ?? 300);
1764
+
1765
+ if ($deviceData) {
1766
+ $deviceData['lastChallengeNonce'] = $nonce;
1767
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1768
+ }
1769
+
1770
+ if ($isApiRequest) {
1771
+ $decision['body'] = [
1772
+ 'challenge' => [
1773
+ 'type' => 'pospace',
1774
+ 'nonce' => $nonce,
1775
+ 'clientSecret' => $clientSecret,
1776
+ 'queries' => $spaceChallenge['queries'],
1777
+ 'sizeMb' => $spaceChallenge['sizeMb'],
1778
+ ]
1779
+ ];
1780
+ } else {
1781
+ $page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
1782
+ $decision['body'] = $page;
1783
+ }
1784
+ return $decision;
1785
+ }
1786
+
1787
+ $cpuChallengeDetails = [
1788
+ 'type' => 'cpu_target',
1789
+ 'nonce' => $nonce,
1790
+ 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1791
+ 'path' => $context->path,
1792
+ ];
1793
+
1794
+ // Perfect linear alignment of the CPU/Memory effort ratio
1795
+ $memActivationFactor = $suspicionFactor;
1796
+ $memDifficulty = (int)round($memActivationFactor * 48); // 0 to 48MB
1797
+
1798
+ $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1799
+ $tlsSessionId = $context->tlsSessionId ?? '';
1800
+ $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
1801
+
1802
+ $isHttps = !empty($context->isHttps);
1803
+ $challengeContext = [
1804
+ 'clientSecret' => $clientSecret,
1805
+ 'cpuTarget' => $cpuChallengeDetails['target'],
1806
+ 'suspicionScore' => $finalScore,
1807
+ 'fingerprint' => $originalFingerprint,
1808
+ 'memDifficulty' => $memDifficulty,
1809
+ 'baseBlock' => $baseBlock,
1810
+ 'originalPath' => $context->path,
1811
+ 'isHttp' => !$isHttps,
1812
+ ];
1813
+
1814
+ $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1815
+
1816
+ // Associate the nonce with the device for trap URL verification
1817
+ if ($deviceData) {
1818
+ $deviceData['lastChallengeNonce'] = $nonce;
1819
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1820
+ }
1821
+
1822
+ $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1823
+ $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1824
+
1825
+ if ($this->logger) {
1826
+ $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1827
+ }
1828
+
1829
+ // For API requests, return a JSON challenge
1830
+ if ($isApiRequest) {
1831
+ $challengePayload = [
1832
+ 'challenge' => [
1833
+ 'type' => 'cpu_mem',
1834
+ 'nonce' => $nonce,
1835
+ 'clientSecret' => $clientSecret,
1836
+ 'cpuTarget' => $cpuChallengeDetails['target'],
1837
+ 'memDifficulty' => $memDifficulty,
1838
+ 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Send as a byte array
1839
+ ]
1840
+ ];
1841
+ $decision['body'] = $challengePayload;
1842
+ } else {
1843
+ // For browsers, return an HTML page
1844
+ $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1845
+ $cpuChallengeDetails, $memDifficulty, $clientSecret,
1846
+ $this->securityConfig, $trapUrls, $originalFingerprint,
1847
+ $context->clientIp, $tlsSessionId, $baseBlock, $isHttps
1848
+ );
1849
+ $decision['body'] = $pageBody;
1850
+ }
1851
+ $response = $decision;
1852
+ } elseif ($hasValidTicket) {
1853
+ // If we get here with a valid ticket and a low score, allow the request
1613
1854
  MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1614
- $this->log('Valid clearance ticket found and score is low - allowing request');
1615
- $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
1616
- } else {
1617
- // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
1855
+ $this->log('Valid clearance ticket found and score is low - allowing request');
1856
+ $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
1857
+ } else {
1858
+ // 6. If the score is low and there is no ticket, allow the request
1618
1859
  MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1619
- $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
1620
- if ($this->logger) {
1860
+ $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
1861
+ if ($this->logger) {
1621
1862
  MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
1622
- $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1623
- }
1624
- $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1625
- }
1626
- }
1627
-
1628
- // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
1629
- if (isset($context->newCookieForResponse)) {
1630
- $response['newCookieForResponse'] = $context->newCookieForResponse;
1631
- }
1632
-
1633
- return $response;
1634
- }
1635
-
1636
- /**
1637
- * Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
1638
- */
1639
- private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
1640
- {
1641
- if (empty($operationType) || empty($operationName)) {
1642
- return false;
1643
- }
1644
-
1645
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1646
- $graphqlRule = null;
1647
- foreach ($whitelistRules as $rule) {
1648
- if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
1649
- $graphqlRule = $rule;
1650
- break;
1651
- }
1652
- }
1653
-
1654
- if (empty($graphqlRule['entries'])) {
1655
- return false;
1656
- }
1657
-
1658
- foreach ($graphqlRule['entries'] as $entry) {
1659
- [$entryType, $entryName] = explode(':', $entry, 2);
1660
- if ($entryType !== $operationType) continue;
1661
-
1662
- if ($entryName === $operationName || $entryName === '*') return true;
1663
-
1664
- if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1665
- }
1666
-
1667
- return false;
1668
- }
1669
-
1670
-
1671
- private function broadcastBannedZkp(string $zkpY): void
1672
- {
1673
- $peers = $this->securityConfig['federatedPeers'] ?? [];
1674
- if (empty($peers)) return;
1675
-
1676
- $dpConfig = $this->securityConfig['differentialPrivacy'] ?? [];
1677
- $dpEnabled = ($dpConfig['enabled'] ?? true) !== false;
1678
- $epsilon = isset($dpConfig['epsilon']) && is_numeric($dpConfig['epsilon'])
1679
- ? (float)$dpConfig['epsilon']
1680
- : (float)($this->securityConfig['dpEpsilon'] ?? 1.0);
1681
-
1682
- $sendThreatReport = function (string $targetZkpY, int $ts) use ($peers) {
1683
- $msg = "{$ts}:{$targetZkpY}";
1684
- $signature = '';
1685
- $isAsymmetric = false;
1686
-
1687
- $privateKey = Env::get('ED25519_PRIVATE_KEY');
1688
- if ($privateKey) {
1689
- try {
1690
- $cleanKey = str_replace('\n', "\n", $privateKey);
1691
- $pkeyObj = openssl_pkey_get_private($cleanKey);
1692
- if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
1693
- $signature = bin2hex($sigBytes);
1694
- $isAsymmetric = true;
1695
- }
1696
- } catch (\Throwable $e) {
1697
- self::logError('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
1698
- }
1699
- }
1700
-
1701
- if (!$isAsymmetric) {
1702
- $secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
1703
- $signature = hash_hmac('sha256', $msg, $secret);
1704
- }
1705
-
1706
- foreach ($peers as $peerUrl) {
1707
- $this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
1708
- 'zkpY' => $targetZkpY,
1709
- 'signature' => $isAsymmetric ? '' : $signature,
1710
- 'signature_ed25519' => $isAsymmetric ? $signature : '',
1711
- 'timestamp' => $ts
1712
- ]);
1713
- }
1714
- };
1715
-
1716
- $now = (int)(microtime(true) * 1000);
1717
- $reportTimestamp = $now;
1718
-
1719
- if ($dpEnabled) {
1720
- // 1. Differential Privacy : Bruit laplacien sur l'horodatage
1721
- $deltaT = 5000;
1722
- $b = $deltaT / max(0.1, $epsilon);
1723
- $u = (random_int(1, 999999) / 1000000.0) - 0.5;
1724
- $safeU = abs($u) < 1e-7 ? ($u >= 0 ? 1e-7 : -1e-7) : $u;
1725
- $laplaceNoise = -$b * ($safeU <=> 0) * log(1.0 - 2.0 * abs($safeU));
1726
- $clampedNoise = (int)max(-60000, min(60000, round($laplaceNoise)));
1727
- $reportTimestamp = $now + $clampedNoise;
1728
- }
1729
-
1730
- $sendThreatReport($zkpY, $reportTimestamp);
1731
-
1732
- if ($dpEnabled) {
1733
- // 2. Differential Privacy : Injection de clés leurres (Decoy ZKP)
1734
- $decoyProb = isset($dpConfig['dummyRate']) && is_numeric($dpConfig['dummyRate'])
1735
- ? (float)$dpConfig['dummyRate']
1736
- : (1.0 / (1.0 + exp($epsilon)));
1737
-
1738
- $randFloat = random_int(0, 999999) / 1000000.0;
1739
- if ($randFloat < $decoyProb) {
1740
- $decoyBytes = random_bytes(32);
1741
- $decoyZkpY = ltrim(bin2hex($decoyBytes), '0') ?: '1';
1742
-
1743
- $uDecoy = (random_int(1, 999999) / 1000000.0) - 0.5;
1744
- $safeUDecoy = abs($uDecoy) < 1e-7 ? ($uDecoy >= 0 ? 1e-7 : -1e-7) : $uDecoy;
1745
- $b = 5000 / max(0.1, $epsilon);
1746
- $decoyNoise = (int)max(-60000, min(60000, round(-$b * ($safeUDecoy <=> 0) * log(1.0 - 2.0 * abs($safeUDecoy)))));
1747
- $decoyTimestamp = $now + $decoyNoise;
1748
-
1749
- $sendThreatReport($decoyZkpY, $decoyTimestamp);
1750
- }
1751
- }
1752
- }
1753
-
1754
- protected function asyncPost(string $url, array $params): void
1755
- {
1756
- // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url used if available
1757
- $parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
1758
- if ($parts === false) return;
1759
-
1760
- $host = $parts['host'];
1761
- $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
1762
- $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
1763
- $scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
1764
-
1765
- $postData = json_encode($params);
1766
-
1767
- $fp = @stream_socket_client(
1768
- "{$scheme}{$host}:{$port}",
1769
- $errno,
1770
- $errstr,
1771
- 0.5,
1772
- STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
1773
- );
1774
-
1775
- if ($fp) {
1776
- stream_set_blocking($fp, false);
1777
- $out = "POST {$path} HTTP/1.1\r\n";
1778
- $out .= "Host: {$host}\r\n";
1779
- $out .= "Content-Type: application/json\r\n";
1780
- $out .= "Content-Length: " . strlen($postData) . "\r\n";
1781
- $out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
1782
- $out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
1783
- $out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
1784
- $out .= "Connection: Close\r\n\r\n";
1785
- $out .= $postData;
1786
-
1787
- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Socket stream write
1788
- @fwrite($fp, $out);
1789
- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Socket stream close
1790
- @fclose($fp);
1791
- }
1792
- }
1793
-
1794
- /**
1795
- * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
1796
- * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
1797
- */
1798
- private function verifyWhitelistedBot(RequestContext $context): bool
1799
- {
1800
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1801
- $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
1802
- if (empty($botRules)) {
1803
- return false;
1804
- }
1805
-
1806
- $userAgent = $context->getHeader('user-agent') ?? '';
1807
- $matchedRule = null;
1808
- foreach ($botRules as $rule) {
1809
- if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
1810
- $matchedRule = $rule;
1811
- break;
1812
- }
1813
- }
1814
- if ($matchedRule === null) {
1815
- return false;
1816
- }
1817
-
1818
- $store = StoreManager::getStore();
1819
- $cacheKey = "ip-whitelist:{$context->clientIp}";
1820
- $cachedStatus = $store->get($cacheKey);
1821
-
1822
- if ($cachedStatus === 'verified') return true;
1823
- if ($cachedStatus === 'failed') return false;
1824
-
1825
- if (!self::canAttemptDns()) {
1826
- return false;
1827
- }
1828
-
1829
- try {
1830
- // 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
1831
- $revName = self::getReverseDnsName($context->clientIp);
1832
- if (!$revName) {
1833
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1834
- return false;
1835
- }
1836
- $hostnames = self::resolveDnsNative($revName, 'PTR');
1837
- if ($hostnames === null || empty($hostnames)) {
1838
- self::recordDnsFailure();
1839
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1840
- return false;
1841
- }
1842
-
1843
- $validHostname = null;
1844
- foreach ($hostnames as $hostname) {
1845
- if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1846
- $validHostname = $hostname;
1847
- break;
1848
- }
1849
- }
1850
-
1851
- if ($validHostname === null) {
1852
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1853
- return false;
1854
- }
1855
-
1856
- // 2. Forward DNS lookup with strict 500ms timeout
1857
- $ips = [];
1858
- $resolvedA = self::resolveDnsNative($validHostname, 'A');
1859
- if ($resolvedA === null) {
1860
- self::recordDnsFailure();
1861
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1862
- return false;
1863
- }
1864
- $ips = array_merge($ips, $resolvedA);
1865
-
1866
- $resolvedAaaa = self::resolveDnsNative($validHostname, 'AAAA');
1867
- if ($resolvedAaaa === null) {
1868
- self::recordDnsFailure();
1869
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1870
- return false;
1871
- }
1872
- $ips = array_merge($ips, $resolvedAaaa);
1873
-
1874
- if (in_array($context->clientIp, $ips, true)) {
1875
- self::recordDnsSuccess();
1876
- $store->set($cacheKey, 'verified', 86400);
1877
- return true;
1878
- }
1879
- } catch (\Exception $e) {
1880
- self::recordDnsFailure();
1881
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1882
- return false;
1883
- }
1884
-
1885
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1886
- return false;
1887
- }
1863
+ $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1864
+ }
1865
+ $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1866
+ }
1867
+ }
1868
+
1869
+ // If a new identification cookie has been generated, add it to the response.
1870
+ if (isset($context->newCookieForResponse)) {
1871
+ $response['newCookieForResponse'] = $context->newCookieForResponse;
1872
+ }
1873
+
1874
+ return $response;
1875
+ }
1876
+
1877
+ /**
1878
+ * Checks whether the GraphQL operation matches a whitelist entry.
1879
+ * Supports exact matches, wildcard '*' for operation name, and
1880
+ * trailing-wildcard prefixes on operation names.
1881
+ *
1882
+ * @param string|null $operationType The GraphQL operation type (query/mutation).
1883
+ * @param string|null $operationName The GraphQL operation name.
1884
+ * @return bool True if the operation is allowlisted, false otherwise.
1885
+ */
1886
+ private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
1887
+ {
1888
+ if (empty($operationType) || empty($operationName)) {
1889
+ return false;
1890
+ }
1891
+
1892
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1893
+ $graphqlRule = null;
1894
+ foreach ($whitelistRules as $rule) {
1895
+ if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
1896
+ $graphqlRule = $rule;
1897
+ break;
1898
+ }
1899
+ }
1900
+
1901
+ if (empty($graphqlRule['entries'])) {
1902
+ return false;
1903
+ }
1904
+
1905
+ foreach ($graphqlRule['entries'] as $entry) {
1906
+ [$entryType, $entryName] = explode(':', $entry, 2);
1907
+ if ($entryType !== $operationType) continue;
1908
+
1909
+ if ($entryName === $operationName || $entryName === '*') return true;
1910
+
1911
+ if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1912
+ }
1913
+
1914
+ return false;
1915
+ }
1916
+
1917
+
1918
+ /**
1919
+ * Broadcasts a banned ZKP public key to all federated peers, with optional
1920
+ * differential privacy (Laplacian timestamp noise and decoy keys).
1921
+ *
1922
+ * @param string $zkpY The ZKP public key to broadcast.
1923
+ * @return void
1924
+ */
1925
+ private function broadcastBannedZkp(string $zkpY): void
1926
+ {
1927
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
1928
+ if (empty($peers)) return;
1929
+
1930
+ $dpConfig = $this->securityConfig['differentialPrivacy'] ?? [];
1931
+ $dpEnabled = ($dpConfig['enabled'] ?? true) !== false;
1932
+ $epsilon = isset($dpConfig['epsilon']) && is_numeric($dpConfig['epsilon'])
1933
+ ? (float)$dpConfig['epsilon']
1934
+ : (float)($this->securityConfig['dpEpsilon'] ?? 1.0);
1935
+
1936
+ $sendThreatReport = function (string $targetZkpY, int $ts) use ($peers) {
1937
+ $msg = "{$ts}:{$targetZkpY}";
1938
+ $signature = '';
1939
+ $isAsymmetric = false;
1940
+
1941
+ $privateKey = Env::get('ED25519_PRIVATE_KEY');
1942
+ if ($privateKey) {
1943
+ try {
1944
+ $cleanKey = str_replace('\n', "\n", $privateKey);
1945
+ $pkeyObj = openssl_pkey_get_private($cleanKey);
1946
+ if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
1947
+ $signature = bin2hex($sigBytes);
1948
+ $isAsymmetric = true;
1949
+ }
1950
+ } catch (\Throwable $e) {
1951
+ self::logError('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
1952
+ }
1953
+ }
1954
+
1955
+ if (!$isAsymmetric) {
1956
+ $secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
1957
+ $signature = hash_hmac('sha256', $msg, $secret);
1958
+ }
1959
+
1960
+ foreach ($peers as $peerUrl) {
1961
+ $this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
1962
+ 'zkpY' => $targetZkpY,
1963
+ 'signature' => $isAsymmetric ? '' : $signature,
1964
+ 'signature_ed25519' => $isAsymmetric ? $signature : '',
1965
+ 'timestamp' => $ts
1966
+ ]);
1967
+ }
1968
+ };
1969
+
1970
+ $now = (int)(microtime(true) * 1000);
1971
+ $reportTimestamp = $now;
1972
+
1973
+ if ($dpEnabled) {
1974
+ // 1. Differential Privacy: Laplacian noise on the timestamp
1975
+ $deltaT = 5000;
1976
+ $b = $deltaT / max(0.1, $epsilon);
1977
+ $u = (random_int(1, 999999) / 1000000.0) - 0.5;
1978
+ $safeU = abs($u) < 1e-7 ? ($u >= 0 ? 1e-7 : -1e-7) : $u;
1979
+ $laplaceNoise = -$b * ($safeU <=> 0) * log(1.0 - 2.0 * abs($safeU));
1980
+ $clampedNoise = (int)max(-60000, min(60000, round($laplaceNoise)));
1981
+ $reportTimestamp = $now + $clampedNoise;
1982
+ }
1983
+
1984
+ $sendThreatReport($zkpY, $reportTimestamp);
1985
+
1986
+ if ($dpEnabled) {
1987
+ // 2. Differential Privacy: Decoy ZKP key injection
1988
+ $decoyProb = isset($dpConfig['dummyRate']) && is_numeric($dpConfig['dummyRate'])
1989
+ ? (float)$dpConfig['dummyRate']
1990
+ : (1.0 / (1.0 + exp($epsilon)));
1991
+
1992
+ $randFloat = random_int(0, 999999) / 1000000.0;
1993
+ if ($randFloat < $decoyProb) {
1994
+ $decoyBytes = random_bytes(32);
1995
+ $decoyZkpY = ltrim(bin2hex($decoyBytes), '0') ?: '1';
1996
+
1997
+ $uDecoy = (random_int(1, 999999) / 1000000.0) - 0.5;
1998
+ $safeUDecoy = abs($uDecoy) < 1e-7 ? ($uDecoy >= 0 ? 1e-7 : -1e-7) : $uDecoy;
1999
+ $b = 5000 / max(0.1, $epsilon);
2000
+ $decoyNoise = (int)max(-60000, min(60000, round(-$b * ($safeUDecoy <=> 0) * log(1.0 - 2.0 * abs($safeUDecoy)))));
2001
+ $decoyTimestamp = $now + $decoyNoise;
2002
+
2003
+ $sendThreatReport($decoyZkpY, $decoyTimestamp);
2004
+ }
2005
+ }
2006
+ }
2007
+
2008
+ /**
2009
+ * Sends an asynchronous HTTP POST request to a given URL with the given
2010
+ * parameters (JSON-encoded). Uses non-blocking stream sockets with a short
2011
+ * connection timeout, so the caller is not delayed.
2012
+ *
2013
+ * @param string $url The target URL.
2014
+ * @param array $params The parameters to JSON-encode and POST.
2015
+ * @return void
2016
+ */
2017
+ protected function asyncPost(string $url, array $params): void
2018
+ {
2019
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url used if available
2020
+ $parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
2021
+ if ($parts === false) return;
2022
+
2023
+ $host = $parts['host'];
2024
+ $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
2025
+ $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
2026
+ $scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
2027
+
2028
+ $postData = json_encode($params);
2029
+
2030
+ $fp = @stream_socket_client(
2031
+ "{$scheme}{$host}:{$port}",
2032
+ $errno,
2033
+ $errstr,
2034
+ 0.5,
2035
+ STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
2036
+ );
2037
+
2038
+ if ($fp) {
2039
+ stream_set_blocking($fp, false);
2040
+ $out = "POST {$path} HTTP/1.1\r\n";
2041
+ $out .= "Host: {$host}\r\n";
2042
+ $out .= "Content-Type: application/json\r\n";
2043
+ $out .= "Content-Length: " . strlen($postData) . "\r\n";
2044
+ $out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
2045
+ $out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
2046
+ $out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
2047
+ $out .= "Connection: Close\r\n\r\n";
2048
+ $out .= $postData;
2049
+
2050
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Socket stream write
2051
+ @fwrite($fp, $out);
2052
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Socket stream close
2053
+ @fclose($fp);
2054
+ }
2055
+ }
2056
+
2057
+ /**
2058
+ * Checks whether a request comes from a legitimate, whitelisted bot (e.g. Googlebot)
2059
+ * using reverse DNS lookup followed by forward DNS lookup (with a circuit breaker
2060
+ * on DNS failures). Results are cached for 24 hours on success and 5 minutes on
2061
+ * failure.
2062
+ *
2063
+ * @param RequestContext $context The request context.
2064
+ * @return bool True if the request is from a verified whitelisted bot, false otherwise.
2065
+ */
2066
+ private function verifyWhitelistedBot(RequestContext $context): bool
2067
+ {
2068
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
2069
+ $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
2070
+ if (empty($botRules)) {
2071
+ return false;
2072
+ }
2073
+
2074
+ $userAgent = $context->getHeader('user-agent') ?? '';
2075
+ $matchedRule = null;
2076
+ foreach ($botRules as $rule) {
2077
+ if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
2078
+ $matchedRule = $rule;
2079
+ break;
2080
+ }
2081
+ }
2082
+ if ($matchedRule === null) {
2083
+ return false;
2084
+ }
2085
+
2086
+ $store = StoreManager::getStore();
2087
+ $cacheKey = "ip-whitelist:{$context->clientIp}";
2088
+ $cachedStatus = $store->get($cacheKey);
2089
+
2090
+ if ($cachedStatus === 'verified') return true;
2091
+ if ($cachedStatus === 'failed') return false;
2092
+
2093
+ if (!self::canAttemptDns()) {
2094
+ return false;
2095
+ }
2096
+
2097
+ try {
2098
+ // 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
2099
+ $revName = self::getReverseDnsName($context->clientIp);
2100
+ if (!$revName) {
2101
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2102
+ return false;
2103
+ }
2104
+ $hostnames = self::resolveDnsNative($revName, 'PTR');
2105
+ if ($hostnames === null || empty($hostnames)) {
2106
+ self::recordDnsFailure();
2107
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2108
+ return false;
2109
+ }
2110
+
2111
+ $validHostname = null;
2112
+ foreach ($hostnames as $hostname) {
2113
+ if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
2114
+ $validHostname = $hostname;
2115
+ break;
2116
+ }
2117
+ }
2118
+
2119
+ if ($validHostname === null) {
2120
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2121
+ return false;
2122
+ }
2123
+
2124
+ // 2. Forward DNS lookup with strict 500ms timeout
2125
+ $ips = [];
2126
+ $resolvedA = self::resolveDnsNative($validHostname, 'A');
2127
+ if ($resolvedA === null) {
2128
+ self::recordDnsFailure();
2129
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2130
+ return false;
2131
+ }
2132
+ $ips = array_merge($ips, $resolvedA);
2133
+
2134
+ $resolvedAaaa = self::resolveDnsNative($validHostname, 'AAAA');
2135
+ if ($resolvedAaaa === null) {
2136
+ self::recordDnsFailure();
2137
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2138
+ return false;
2139
+ }
2140
+ $ips = array_merge($ips, $resolvedAaaa);
2141
+
2142
+ if (in_array($context->clientIp, $ips, true)) {
2143
+ self::recordDnsSuccess();
2144
+ $store->set($cacheKey, 'verified', 86400);
2145
+ return true;
2146
+ }
2147
+ } catch (\Exception $e) {
2148
+ self::recordDnsFailure();
2149
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2150
+ return false;
2151
+ }
2152
+
2153
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
2154
+ return false;
2155
+ }
1888
2156
 
1889
2157
  /**
1890
2158
  * @internal For testing purposes only.
2159
+ *
2160
+ * Returns the list of registered problems from the ProblemManager singleton.
2161
+ *
2162
+ * @return array The list of problems.
1891
2163
  */
1892
2164
  public function getProblems(): array
1893
2165
  {
1894
2166
  $problemManager = ProblemManager::getInstance();
1895
2167
  return $problemManager->getProblems();
1896
2168
  }
2169
+
2170
+ /**
2171
+ * Decodes a CBOR-encoded string (used for WebAuthn attestation objects).
2172
+ * Supports major types 0-5 (unsigned int, negative int, byte string,
2173
+ * text string, array, map).
2174
+ *
2175
+ * @param string $str The CBOR-encoded input.
2176
+ * @param int &$offset The current byte offset (updated in place).
2177
+ * @return mixed The decoded value.
2178
+ * @throws \Exception On malformed CBOR or unsupported integer sizes.
2179
+ */
1897
2180
  private static function decodeCBOR(string $str, int &$offset = 0)
1898
2181
  {
1899
2182
  if ($offset >= strlen($str)) throw new \Exception("End of CBOR");
@@ -1945,6 +2228,12 @@
1945
2228
  return null;
1946
2229
  }
1947
2230
 
2231
+ /**
2232
+ * Returns the list of trusted hardware attestation root certificates
2233
+ * (e.g. Yubico, FIDO Alliance) used to validate WebAuthn attestation chains.
2234
+ *
2235
+ * @return array The list of PEM-encoded trusted root certificates.
2236
+ */
1948
2237
  private static function getTrustedHardwareRoots(): array
1949
2238
  {
1950
2239
  return [
@@ -1966,6 +2255,15 @@
1966
2255
  ];
1967
2256
  }
1968
2257
 
2258
+ /**
2259
+ * Verifies a WebAuthn hardware anchor, handling both registration
2260
+ * (attestation chain validation against trusted roots) and assertion
2261
+ * (signature verification against the stored public key).
2262
+ *
2263
+ * @param array $anchor The WebAuthn anchor data (type, clientDataJSON, etc.).
2264
+ * @param array &$deviceData The device data (updated in place on registration).
2265
+ * @return bool True if the anchor is valid, false otherwise.
2266
+ */
1969
2267
  private function verifyWebAuthnHardwareAnchor(array $anchor, array &$deviceData): bool
1970
2268
  {
1971
2269
  if (empty($anchor['type'])) return false;
@@ -1981,7 +2279,7 @@
1981
2279
  $attestationBytes = base64_decode($anchor['attestationObject']);
1982
2280
  $offset = 0;
1983
2281
  $decoded = self::decodeCBOR($attestationBytes, $offset);
1984
-
2282
+
1985
2283
  if (!$decoded || empty($decoded['fmt']) || empty($decoded['attStmt'])) {
1986
2284
  return false;
1987
2285
  }
@@ -2040,9 +2338,15 @@
2040
2338
  return false;
2041
2339
  }
2042
2340
 
2341
+ /**
2342
+ * Logs an error message using the PHP error log.
2343
+ *
2344
+ * @param string $message The message to log.
2345
+ * @return void
2346
+ */
2043
2347
  private static function logError(string $message): void
2044
2348
  {
2045
2349
  // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Engine diagnostic error log
2046
2350
  error_log($message);
2047
2351
  }
2048
- }
2352
+ }