@anonympins/fingerprint 0.7.5 → 0.8.0

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