@anonympins/fingerprint 0.6.6 → 0.6.7

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.
@@ -1,1879 +1,1880 @@
1
- <?php
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
-
35
- private static array $dnsCircuitBreaker = [
36
- 'state' => 'CLOSED',
37
- 'failureCount' => 0,
38
- 'lastStateChange' => 0,
39
- 'threshold' => 5,
40
- 'cooldown' => 30, // seconds
41
- ];
42
-
43
- private static function recordDnsSuccess(): void
44
- {
45
- self::$dnsCircuitBreaker['failureCount'] = 0;
46
- self::$dnsCircuitBreaker['state'] = 'CLOSED';
47
- }
48
-
49
- private static function recordDnsFailure(): void
50
- {
51
- self::$dnsCircuitBreaker['failureCount']++;
52
- if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
53
- self::$dnsCircuitBreaker['state'] = 'OPEN';
54
- self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
55
- }
56
- }
57
-
58
- private static function canAttemptDns(): bool
59
- {
60
- if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
61
- return true;
62
- }
63
- if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
64
- if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
65
- self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
66
- return true;
67
- }
68
- return false;
69
- }
70
- return true; // HALF-OPEN
71
- }
72
-
73
- private static function getReverseDnsName(string $ip): ?string
74
- {
75
- if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
76
- return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
77
- } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
78
- $hex = bin2hex((string)inet_pton($ip));
79
- return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
80
- }
81
- return null;
82
- }
83
-
84
- private static function dnsResolveUdp(string $query, string $type, float $timeout = 0.5): ?array
85
- {
86
- $dnsServer = '8.8.8.8';
87
- $port = 53;
88
- $fp = @stream_socket_client("udp://$dnsServer:$port", $errno, $errstr, $timeout);
89
- if (!$fp) return null;
90
- stream_set_timeout($fp, 0, (int)($timeout * 1000000));
91
-
92
- $id = rand(10000, 65000);
93
- $header = pack('n6', $id, 0x0100, 1, 0, 0, 0);
94
-
95
- $qType = ($type === 'PTR') ? 12 : (($type === 'AAAA') ? 28 : 1);
96
- $parts = explode('.', $query);
97
- $qName = '';
98
- foreach ($parts as $part) {
99
- $qName .= chr(strlen($part)) . $part;
100
- }
101
- $qName .= chr(0);
102
- $question = $qName . pack('n2', $qType, 1);
103
-
104
- if (!@fwrite($fp, $header . $question)) {
105
- fclose($fp);
106
- return null;
107
- }
108
-
109
- $response = @fread($fp, 512);
110
- fclose($fp);
111
- if (!$response || strlen($response) < 12) return null;
112
-
113
- $resHeader = unpack('n6', substr($response, 0, 12));
114
- if ($resHeader[1] !== $id) return null;
115
-
116
- $answersCount = $resHeader[4];
117
- if ($answersCount <= 0) return [];
118
-
119
- $offset = 12 + strlen($question);
120
- $results = [];
121
- for ($i = 0; $i < $answersCount; $i++) {
122
- if ($offset + 12 > strlen($response)) break;
123
- if ((ord($response[$offset]) & 0xc0) === 0xc0) {
124
- $offset += 2;
125
- } else {
126
- while ($offset < strlen($response) && ord($response[$offset]) !== 0) {
127
- $offset += 1 + ord($response[$offset]);
128
- }
129
- $offset++;
130
- }
131
- if ($offset + 10 > strlen($response)) break;
132
- $typeAndClass = unpack('n2type_class/Nttl/nlen', substr($response, $offset, 10));
133
- $rType = $typeAndClass['type_class1'];
134
- $rLen = $typeAndClass['len'];
135
- $offset += 10;
136
- if ($offset + $rLen > strlen($response)) break;
137
- $rData = substr($response, $offset, $rLen);
138
- $offset += $rLen;
139
-
140
- if ($rType === 12) {
141
- $ptrName = '';
142
- $p = 0;
143
- while ($p < strlen($rData)) {
144
- $l = ord($rData[$p]);
145
- if ($l === 0) break;
146
- if (($l & 0xc0) === 0xc0) {
147
- break;
148
- }
149
- $ptrName .= substr($rData, $p + 1, $l) . '.';
150
- $p += 1 + $l;
151
- }
152
- $results[] = rtrim($ptrName, '.');
153
- } elseif ($rType === 1) {
154
- if ($rLen === 4) {
155
- $results[] = long2ip(unpack('N', $rData)[1]);
156
- }
157
- } elseif ($rType === 28) {
158
- if ($rLen === 16) {
159
- $results[] = inet_ntop($rData);
160
- }
161
- }
162
- }
163
- return $results;
164
- }
165
-
166
- public function __construct(array $securityConfig)
167
- {
168
- $this->isProduction = Env::get('APP_ENV') === 'production';
169
-
170
- // Dynamically bind Ed25519 keys if passed via config
171
- if (isset($securityConfig['ed25519_private_key'])) {
172
- Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
173
- }
174
- if (isset($securityConfig['ed25519_public_key'])) {
175
- Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
176
- }
177
-
178
- // Génération et persistance automatique de la paire de clés Ed25519
179
- $useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
180
- $envPrivate = Env::get('ED25519_PRIVATE_KEY');
181
- if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
182
- $configDir = dirname(__DIR__, 1) . '/config';
183
- $persistentKeyPath = $configDir . '/ed25519_key.json';
184
-
185
- if (file_exists($persistentKeyPath)) {
186
- try {
187
- $keys = json_decode(file_get_contents($persistentKeyPath), true);
188
- if (isset($keys['privateKey'], $keys['publicKey'])) {
189
- Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
190
- Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
191
- }
192
- } catch (\Throwable $e) {
193
- error_log('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
194
- }
195
- } else {
196
- try {
197
- if (defined('OPENSSL_KEYTYPE_ED25519')) {
198
- $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
199
- if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
200
- $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
201
- $publicKeyPem = $details['key']; // @phpstan-ignore-line
202
- Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
203
- Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
204
- // Also update $_ENV and $_SERVER for consistency
205
- if (!is_dir($configDir)) {
206
- mkdir($configDir, 0777, true);
207
- }
208
- file_put_contents($persistentKeyPath, json_encode([
209
- 'privateKey' => $privateKeyPem,
210
- 'publicKey' => $publicKeyPem
211
- ], JSON_PRETTY_PRINT));
212
- }
213
- }
214
- } catch (\Throwable $e) {
215
- error_log('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
216
- }
217
- }
218
- }
219
-
220
- // Auto-load optimized config if autotuning savePath is specified
221
- if (isset($securityConfig['autotuning']['savePath'])) {
222
- $savePath = $securityConfig['autotuning']['savePath'];
223
- if (file_exists($savePath)) {
224
- try {
225
- $savedConfig = json_decode(file_get_contents($savePath), true);
226
- if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
227
- $securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
228
- }
229
- } catch (\Throwable $e) {
230
- error_log("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
231
- }
232
- }
233
- }
234
- $this->securityConfig = $securityConfig;
235
- $this->verbose = $securityConfig['verbose'] ?? false;
236
- $this->allowlist = $this->buildAllowlist();
237
- $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
238
- $this->dryRun = $securityConfig['dryRun'] ?? false;
239
- $this->validateConfig($securityConfig);
240
- if ($this->securityConfig['reset'] ?? false) {
241
- $this->resetStore();
242
- }
243
- }
244
-
245
- /**
246
- * Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
247
- * sans nécessiter de redémarrage.
248
- *
249
- * @param array $newConfig La nouvelle configuration (partielle ou complète).
250
- */
251
- public function updateConfig(array $newConfig): void
252
- {
253
- $this->validateConfig($newConfig);
254
- $this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
255
- $this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
256
- }
257
-
258
- /**
259
- * Réinitialise le store de persistance actif.
260
- */
261
- public function resetStore(): void
262
- {
263
- $store = StoreManager::getStore();
264
- if (method_exists($store, 'clear')) {
265
- $store->clear();
266
- }
267
- $this->log('Store has been reset/cleared.');
268
- }
269
-
270
- private function validateConfig(array $config): void
271
- {
272
- if (empty($config)) {
273
- $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
274
- return;
275
- }
276
-
277
- $knownKeys = [
278
- 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
279
- 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
280
- 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
281
- 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
282
- 'similarityThreshold', 'summary', 'description',
283
- 'ed25519_private_key', 'ed25519_public_key',
284
- 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset'
285
- ];
286
-
287
- if (empty($config['weights'])) {
288
- $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
289
- }
290
- if (empty($config['thresholds'])) {
291
- $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
292
- }
293
-
294
- foreach (array_keys($config) as $key) {
295
- if (!in_array($key, $knownKeys, true)) {
296
- $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
297
- }
298
- }
299
- }
300
- private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
301
- {
302
- $configDir = dirname(__DIR__, 2) . '/config';
303
- $filePath = $configDir . '/' . $filename;
304
- if (file_exists($filePath)) {
305
- try {
306
- $content = file_get_contents($filePath);
307
- if ($content !== false) {
308
- $decoded = json_decode($content, true);
309
- if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
310
- return $decoded;
311
- }
312
- }
313
- } catch (\Throwable $e) {
314
- error_log("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
315
- }
316
- }
317
- return $fallbackEntries;
318
- }
319
-
320
- public static function googlebot_whitelist(): array
321
- {
322
- if (self::$googlebotEntries === null) {
323
- self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
324
- "2001:4860:4801:10::/64",
325
- "2001:4860:4801:11::/64",
326
- "2001:4860:4801:12::/64",
327
- "66.249.79.64"
328
- ]);
329
- }
330
- return [
331
- 'type' => 'allowlist',
332
- 'entries' => self::$googlebotEntries
333
- ];
334
- }
335
-
336
- public static function yandex_whitelist(): array
337
- {
338
- if (self::$yandexEntries === null) {
339
- self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
340
- "2a02:6b8::/29",
341
- "5.45.192.0/18",
342
- "213.180.192.0/19"
343
- ]);
344
- }
345
- return [
346
- 'type' => 'allowlist',
347
- 'entries' => self::$yandexEntries
348
- ];
349
- }
350
-
351
- public static function bingbot_whitelist(): array
352
- {
353
- if (self::$bingbotEntries === null) {
354
- self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
355
- "157.55.39.0/24",
356
- "207.46.13.0/24",
357
- "40.77.178.0/23"
358
- ]);
359
- }
360
- return [
361
- 'type' => 'allowlist',
362
- 'entries' => self::$bingbotEntries
363
- ];
364
- }
365
-
366
- public static function default_whitelist(): array
367
- {
368
- return [
369
- self::googlebot_whitelist(),
370
- self::bingbot_whitelist(),
371
- self::yandex_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
- error_log($logMessage);
407
- }
408
- }
409
- }
410
-
411
- public function calculateFinalScore(array $suspicionVector): float
412
- {
413
- $weights = $this->securityConfig['weights'] ?? [];
414
- if (empty($weights)) {
415
- return 0.0;
416
- }
417
-
418
- $score = 0.0;
419
- foreach ($weights as $key => $weight) {
420
- $score += ($suspicionVector[$key] ?? 0) * $weight;
421
- }
422
-
423
- return min(100.0, $score);
424
- }
425
-
426
- private function buildAllowlist(): BlockList
427
- {
428
- $blockList = new BlockList();
429
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
430
-
431
- $allowlistRule = null;
432
- foreach ($whitelistRules as $rule) {
433
- if (($rule['type'] ?? '') === 'allowlist') {
434
- $allowlistRule = $rule;
435
- break;
436
- }
437
- }
438
-
439
- if (empty($allowlistRule['entries'])) {
440
- return $blockList;
441
- }
442
-
443
- foreach ($allowlistRule['entries'] as $entry) {
444
- $blockList->add($entry);
445
- }
446
- return $blockList;
447
- }
448
-
449
- private function isIpInAllowlist(string $clientIp): bool
450
- {
451
- return $this->allowlist->check($clientIp);
452
- }
453
-
454
- private function isPathInAllowlist(string $requestPath): bool
455
- {
456
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
457
- $pathAllowlistRule = null;
458
- foreach ($whitelistRules as $rule) {
459
- if (($rule['type'] ?? '') === 'path_allowlist') {
460
- $pathAllowlistRule = $rule;
461
- break;
462
- }
463
- }
464
-
465
- if (empty($pathAllowlistRule['entries'])) {
466
- return false;
467
- }
468
-
469
- foreach ($pathAllowlistRule['entries'] as $entry) {
470
- if (str_ends_with($entry, '*')) {
471
- $base = substr($entry, 0, -1);
472
- if (str_starts_with($requestPath, $base)) {
473
- return true;
474
- }
475
- } elseif ($requestPath === $entry) {
476
- return true;
477
- }
478
- }
479
-
480
- return false;
481
- }
482
-
483
- private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
484
- {
485
- if (empty($requestHost)) {
486
- return false;
487
- }
488
-
489
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
490
- $hostPathRule = null;
491
- foreach ($whitelistRules as $rule) {
492
- if (($rule['type'] ?? '') === 'host_path_allowlist') {
493
- $hostPathRule = $rule;
494
- break;
495
- }
496
- }
497
-
498
- if (empty($hostPathRule['entries'])) {
499
- return false;
500
- }
501
-
502
- foreach ($hostPathRule['entries'] as $entry) {
503
- if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
504
- return true;
505
- }
506
- }
507
- return false;
508
- }
509
-
510
- /**
511
- * Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
512
- */
513
- private function checkAllowlists(RequestContext $context): bool
514
- {
515
- $whitelisted = false;
516
- $type = '';
517
-
518
- if ($this->isIpInAllowlist($context->clientIp)) {
519
- $whitelisted = true;
520
- $type = 'allowlist';
521
- } elseif ($this->isPathInAllowlist($context->path)) {
522
- $whitelisted = true;
523
- $type = 'path_allowlist';
524
- } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
525
- $whitelisted = true;
526
- $type = 'host_path_allowlist';
527
- } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
528
- $whitelisted = true;
529
- $type = 'graphql_operation_allowlist';
530
- } elseif ($this->verifyWhitelistedBot($context)) {
531
- $whitelisted = true;
532
- $type = 'bot';
533
- }
534
-
535
- if ($whitelisted) {
536
- $filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
537
- $bypassWhitelist = false;
538
- if ($filterWhitelist === true) {
539
- $bypassWhitelist = $this->hasCertainAttack($context);
540
- } elseif (is_numeric($filterWhitelist)) {
541
- if ($context->preCalculatedScore === null) {
542
- $suspicionVector = [];
543
- $context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
544
- $context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
545
- }
546
- if ($context->preCalculatedScore > $filterWhitelist) {
547
- $bypassWhitelist = true;
548
- }
549
- }
550
-
551
- if ($bypassWhitelist) {
552
- $this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
553
- return false;
554
- }
555
- $this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
556
- return true;
557
- }
558
-
559
- return false;
560
- }
561
-
562
- /**
563
- * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
564
- */
565
- private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
566
- {
567
- if ($context->resolvedIdentity !== null) {
568
- return $context->resolvedIdentity;
569
- }
570
- $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
571
- $store = StoreManager::getStore();
572
- $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
573
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
574
- $pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
575
-
576
- $deviceId = $existingDeviceId;
577
- $deviceData = null;
578
- $newCookie = null;
579
-
580
- // Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
581
- $tlsSessionId = $context->tlsSessionId;
582
- if (!$deviceId && $tlsSessionId) {
583
- $resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
584
- if ($resumedDeviceId) {
585
- $deviceId = $resumedDeviceId;
586
- $this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
587
- }
588
- }
589
-
590
- if ($deviceId) {
591
- $deviceData = $store->get("device:{$deviceId}");
592
- }
593
-
594
- if ($deviceData === null) {
595
- // Nouvel utilisateur ou cookie perdu/invalide
596
- $pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
597
- if ($pendingDeviceId && !$existingDeviceId) {
598
- // La pénalité est maintenant ajoutée directement au vecteur de suspicion.
599
- $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
600
- $suspicionVector['cookieDroppingScore'] = 100.0;
601
- }
602
-
603
- $deviceId = bin2hex(random_bytes(16)); // UUID-like
604
-
605
- $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
606
- (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
607
- ($context->getHeader('x-forwarded-proto') === 'https');
608
- $secureOption = $isHttps || $this->isProduction;
609
-
610
- // Préparer le cookie à envoyer
611
- $newCookie = [
612
- 'name' => 'device_id',
613
- 'value' => $deviceId,
614
- 'options' => [
615
- 'httponly' => true,
616
- 'secure' => $secureOption,
617
- 'partitioned' => $secureOption,
618
- 'samesite' => 'Strict',
619
- 'path' => '/',
620
- ]
621
- ];
622
- if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
623
- $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
624
- }
625
-
626
- $store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
627
-
628
- $deviceData = [
629
- 'initialDeviceHash' => $currentDeviceHash,
630
- 'ips' => [$context->clientIp],
631
- 'requestHistory' => [],
632
- 'lastUpdate' => time() * 1000,
633
- 'lastFpHash' => $currentDeviceHash,
634
- 'lastChangeTimestamp' => 0,
635
- 'rapidChangeCount' => 0,
636
- 'highScoreCount' => 0,
637
- 'lastHighScoreTimestamp' => 0,
638
- ];
639
- } else {
640
- // S'assurer que 'ips' est un tableau pour les opérations suivantes.
641
- if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
642
- $deviceData['ips'] = [];
643
- }
644
- }
645
-
646
- // Bind the current TLS session ID to the device ID
647
- if ($deviceId && $tlsSessionId) {
648
- $store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
649
- }
650
-
651
- $context->resolvedIdentity = [
652
- 'deviceId' => $deviceId,
653
- 'deviceData' => $deviceData,
654
- 'newCookie' => $newCookie,
655
- ];
656
- return $context->resolvedIdentity;
657
- }
658
-
659
- /**
660
- * @return array<string, float>
661
- */
662
- public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
663
- {
664
- $store = StoreManager::getStore();
665
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
666
- $deviceData = $identity['deviceData'];
667
- $deviceId = $identity['deviceId'];
668
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
669
- $suspicionVector['honeypotScore'] = 100;
670
- return $suspicionVector;
671
- }
672
-
673
- // Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
674
- // pour que le code appelant puisse le gérer.
675
- if ($identity['newCookie']) {
676
- // Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
677
- $context->newCookieForResponse = $identity['newCookie'];
678
- }
679
-
680
- // Nettoyage périodique des données de l'appareil
681
- if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
682
- $deviceData['ips'] = [];
683
- $deviceData['rapidChangeCount'] = 0;
684
- $deviceData['ipTimes'] = [];
685
- }
686
- $deviceData['lastUpdate'] = time() * 1000;
687
-
688
- // --- Calcul des différents scores de suspicion ---
689
-
690
- // Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
691
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
692
- $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
693
- $inconsistencyScore = min(100.0, max(0.0, (1 - $consistencyScore) * 200));
694
- if ($consistencyScore < ($this->securityConfig['similarityThreshold'] ?? 0.7)) {
695
- $inconsistencyScore = 100.0;
696
- }
697
-
698
- $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
699
-
700
- // Score des anomalies d'en-têtes
701
- $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
702
-
703
- // Score de spoofing TLS
704
- $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
705
- $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
706
-
707
- // Advanced JA4 TLS Inconsistency checks
708
- $ja4 = $context->getHeader('x-ja4-hash');
709
- if ($ja4) {
710
- $spoofedJa4s = [
711
- 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
712
- 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
713
- 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
714
- 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
715
- 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
716
- 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
717
- ];
718
- if (in_array($ja4, $spoofedJa4s, true)) {
719
- $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
720
- }
721
-
722
- $parsedJa4 = $this->parseJa4($ja4);
723
- if ($parsedJa4) {
724
- $ua = $context->getHeader('user-agent') ?? '';
725
- $uaParts = $this->parseUserAgent($ua);
726
-
727
- // Check 1: Incohérence ALPN / HTTP Version
728
- $httpVersion = $context->httpVersion ?? '1.1';
729
- if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
730
- $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
731
- if (!$hasProxy) {
732
- $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
733
- }
734
- }
735
-
736
- // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
737
- if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
738
- $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
739
- }
740
-
741
- // Check 3: Incohérence User-Agent vs Signature JA4
742
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
743
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
744
- }
745
- if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
746
- $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
747
- }
748
-
749
- // Check 4: La stagnation (Lack of Entropy / Genericity)
750
- if (is_string($uaParts['browser'])) {
751
- $ja4Key = "ja4-browsers:{$ja4}";
752
- $seenBrowsers = $store->get($ja4Key) ?: [];
753
- if (!is_array($seenBrowsers)) {
754
- $seenBrowsers = [];
755
- }
756
- $browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
757
- if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
758
- $seenBrowsers[] = $browserFamily;
759
- $store->set($ja4Key, $seenBrowsers, 86400);
760
- }
761
- if (count($seenBrowsers) > 1) {
762
- $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
763
- }
764
- }
765
- }
766
- }
767
-
768
- // Score d'incohérence temporelle (attaque par rejeu)
769
- $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
770
-
771
- // Score des incohérences entre couches (client vs serveur)
772
- $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
773
-
774
- // Score des patterns de requêtes (scraping, vélocité)
775
- $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
776
-
777
- // Score des honeypots
778
- $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
779
-
780
- // Score des métriques comportementales client (souris, clavier)
781
- $behavior = RequestUtils::getBehaviorScore($context);
782
-
783
- // Score de détection de bot explicite (marqueurs d'automatisation)
784
- $bot = RequestUtils::getBotScore($context);
785
-
786
- // Score de variance des clics
787
- $clickVariance = RequestUtils::getClickVarianceScore($context);
788
-
789
- // Score basé sur les listes de menaces (Threat Intelligence)
790
- $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
791
-
792
- // Score d'incohérence des Client-Hints
793
- $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
794
-
795
- // Score de similarité globale de l'empreinte (Clustering Botnet)
796
- $stableFp = RequestUtils::extractStablePart($currentDeviceHash);
797
- $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
798
- $botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
799
-
800
- // NOUVEAU: Score de réputation du sous-réseau IP
801
- $subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
802
-
803
- // Score d'anomalie de pile TCP/IP
804
- $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
805
-
806
- // Score d'anomalie de protocole (HTTP/2 et QUIC)
807
- $protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
808
-
809
- // Score d'anomalie de rendu d'affichage (V-Sync)
810
- $renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
811
-
812
- // Assemblage du vecteur de suspicion final
813
- $suspicionVector = array_merge($suspicionVector, [
814
- 'inconsistencyScore' => $inconsistencyScore,
815
- 'historyScore' => $behavioral['historyScore'],
816
- 'rotationScore' => $behavioral['rotationScore'],
817
- 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
818
- 'tlsSpoofingScore' => $tlsSpoofingScore,
819
- 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
820
- 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
821
- 'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
822
- 'honeypotScore' => $honeypot['honeypotScore'],
823
- 'behaviorScore' => $behavior['behaviorScore'],
824
- 'botScore' => $bot['botScore'],
825
- 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
826
- 'threatIntelScore' => $threatIntel['threatIntelScore'],
827
- 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
828
- 'subnetScore' => $subnetScore['subnetScore'],
829
- 'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
830
- 'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
831
- 'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
832
- 'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
833
- ]);
834
-
835
- // Sauvegarder l'état mis à jour de l'appareil dans le store
836
- $store->set("device:{$deviceId}", $deviceData);
837
-
838
- return $suspicionVector;
839
- }
840
-
841
- /**
842
- * Parse a basic User-Agent string.
843
- */
844
- private function parseUserAgent(string $ua): array
845
- {
846
- $result = ['browser' => null, 'os' => null];
847
-
848
- if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
849
- $result['browser'] = 'Chrome';
850
- if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
851
- $result['browser'] .= '/' . $matches[1];
852
- }
853
- } elseif (str_contains($ua, 'Firefox')) {
854
- $result['browser'] = 'Firefox';
855
- if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
856
- $result['browser'] .= '/' . $matches[1];
857
- }
858
- } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
859
- $result['browser'] = 'Safari';
860
- if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
861
- $result['browser'] .= '/' . $matches[1];
862
- }
863
- } elseif (str_contains($ua, 'Edg')) {
864
- $result['browser'] = 'Edge';
865
- if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
866
- $result['browser'] .= '/' . $matches[1];
867
- }
868
- }
869
-
870
- if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
871
- elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
872
- elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
873
- elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
874
- elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
875
- elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
876
-
877
- return $result;
878
- }
879
-
880
- /**
881
- * Parse JA4 string into protocol, version, ALPN etc.
882
- */
883
- private function parseJa4(?string $ja4): ?array
884
- {
885
- if (empty($ja4)) return null;
886
- $parts = explode('_', $ja4);
887
- $ja4a = $parts[0];
888
- if (strlen($ja4a) < 10) return null;
889
- return [
890
- 'protocol' => $ja4a[0],
891
- 'version' => substr($ja4a, 1, 2),
892
- 'sni' => $ja4a[3],
893
- 'ciphersCount' => (int)substr($ja4a, 4, 2),
894
- 'extensionsCount' => (int)substr($ja4a, 6, 2),
895
- 'alpn' => substr($ja4a, 8, 2),
896
- 'ja4b' => $parts[1] ?? null,
897
- 'ja4c' => $parts[2] ?? null
898
- ];
899
- }
900
-
901
-
902
- private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
903
- {
904
- if (empty($mapping['keys'])) {
905
- return $fpString;
906
- }
907
- $reverseKeys = array_flip($mapping['keys']);
908
- $parts = explode('|', $fpString);
909
- $mappedParts = [];
910
- foreach ($parts as $part) {
911
- $pair = explode(':', $part, 2);
912
- if (count($pair) === 2) {
913
- $origKey = $reverseKeys[$pair[0]] ?? $pair[0];
914
- $mappedParts[] = "{$origKey}:{$pair[1]}";
915
- } else {
916
- $mappedParts[] = $part;
917
- }
918
- }
919
- return implode('|', $mappedParts);
920
- }
921
- private function translatePolymorphicHeaders(RequestContext $context): void
922
- {
923
- $store = StoreManager::getStore();
924
- $activeMappings = $store->get('active-polymorphic-mappings') ?: [];
925
- if (!is_array($activeMappings)) {
926
- return;
927
- }
928
-
929
- foreach ($activeMappings as $mapping) {
930
- $devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
931
- $behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
932
-
933
- if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
934
- $context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
935
- if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
936
- $context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
937
- }
938
-
939
- $clientFp = $context->headers['x-device-fingerprint'];
940
- if ($clientFp && is_string($clientFp)) {
941
- $context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
942
- }
943
- break;
944
- }
945
- }
946
- }
947
-
948
- /**
949
- * Évalue si la requête présente des caractéristiques d'attaque flagrantes ou a déclenché un honeypot.
950
- */
951
- private function hasCertainAttack(RequestContext $context): bool
952
- {
953
- $honeypotConfig = $this->securityConfig['honeypot'] ?? [];
954
- $honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
955
- if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
956
- return true;
957
- }
958
- $bot = RequestUtils::getBotScore($context);
959
- if (($bot['botScore'] ?? 0.0) >= 100.0) {
960
- return true;
961
- }
962
- return false;
963
- }
964
-
965
- /**
966
- * Traite une requête entrante et retourne une décision.
967
- * @param RequestContext $context Le contexte de la requête.
968
- * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
969
- */
970
- public function processRequest(RequestContext $context): array
971
- {
972
- $this->translatePolymorphicHeaders($context);
973
-
974
- // Initialiser le vecteur de suspicion pour éviter les erreurs de type.
975
- $suspicionVector = [];
976
-
977
- $coopOp = $context->query['coop_op'] ?? null;
978
- if ($coopOp) {
979
- $result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
980
- return [
981
- 'action' => 'challenge',
982
- 'status' => 200,
983
- 'body' => $result
984
- ];
985
- }
986
-
987
- $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
988
-
989
- // Parse GraphQL query if applicable
990
- if ($context->path === '/graphql' && !empty($context->body)) {
991
- $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
992
- if ($gqlInfo) {
993
- $context->graphqlOperation = $gqlInfo;
994
- }
995
- }
996
-
997
- // 1. Vérifier les listes blanches
998
- if ($this->checkAllowlists($context)) {
999
- MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
1000
- return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
1001
- }
1002
-
1003
- // Initialiser le vecteur de suspicion
1004
- $thresholds = $this->securityConfig['thresholds'];
1005
-
1006
- // 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
1007
- $powNonce = $context->query['pow_nonce'] ?? null;
1008
- $isChallengeSubmission = $powNonce && (
1009
- isset($context->query['pow_solution']) ||
1010
- isset($context->query['pow_solution_cpu']) ||
1011
- isset($context->query['pow_solution_space']) ||
1012
- (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
1013
- );
1014
- if ($isChallengeSubmission) {
1015
- $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
1016
- $store = StoreManager::getStore();
1017
- $challengeContext = $store->get("secret:{$powNonce}");
1018
- $powType = $context->query['pow_type'] ?? null;
1019
-
1020
- if ($challengeContext) {
1021
- $isValid = false;
1022
- $ticket = null;
1023
-
1024
- // Vérification de la cohérence du fingerprint
1025
- $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
1026
- $originalFingerprint = $challengeContext['fingerprint'] ?? '';
1027
- $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
1028
- $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
1029
-
1030
- if ($similarity < $similarityThreshold) {
1031
- $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
1032
- 'similarity' => round($similarity, 4),
1033
- 'threshold' => $similarityThreshold
1034
- ], 'warn');
1035
- $isValid = false;
1036
- } else {
1037
- // Le fingerprint est cohérent, on peut valider la solution
1038
- if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
1039
- $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
1040
- if ($cpuSolution) {
1041
- $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
1042
- $isValid = $ticket !== null;
1043
-
1044
- if ($powType === 'cpu_mem') {
1045
- $memSolution = $context->query['pow_solution_mem'] ?? null;
1046
- $isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
1047
- $isValid = $isValid && $isMemValid;
1048
- }
1049
- }
1050
- } elseif ($powType === 'pospace') {
1051
- $powSolutionSpace = $context->query['pow_solution_space'] ?? null;
1052
- if ($powSolutionSpace && isset($challengeContext['queries'])) {
1053
- $isSpaceValid = ChallengeUtils::verifySpacePoW(
1054
- $powNonce,
1055
- $powSolutionSpace,
1056
- $challengeContext['queries'],
1057
- $powNonce . ":" . $challengeContext['clientSecret'],
1058
- $challengeContext['clientSecret']
1059
- );
1060
- $isValid = $isSpaceValid;
1061
- if ($isValid) {
1062
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1063
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1064
- $ticket = ChallengeUtils::generateStatelessTicket([
1065
- 'expiry' => $expiry,
1066
- 'originalIp' => $context->clientIp,
1067
- 'deviceId' => '',
1068
- 'deviceHash' => ''
1069
- ]);
1070
- }
1071
- }
1072
- } elseif ($powType === 'useful_work_task') {
1073
- $problemId = $context->query['pow_problem_id'] ?? null;
1074
- $workResultJson = $context->query['pow_solution_work_result'] ?? null;
1075
- if ($problemId && $workResultJson) {
1076
- $workResult = json_decode($workResultJson, true);
1077
- $this->log('Verifying useful work solution.', [
1078
- 'problemId' => $problemId,
1079
- 'receivedData' => $workResult,
1080
- 'jsonLastError' => json_last_error_msg()
1081
- ]);
1082
- if (json_last_error() === JSON_ERROR_NONE) {
1083
- // @phpstan-ignore-next-line - L'instance est gérée par le singleton
1084
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1085
- $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1086
- $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
1087
- // FIX: La solution est directement le $workResult, pas une sous-propriété.
1088
- $problemManager->integrateSolution($problemId, $workResult);
1089
-
1090
- // Si le problème résolu est l'auto-tuning de sécurité et que l'auto-tuning est activé,
1091
- // on applique directement la meilleure solution calculée au moteur en direct.
1092
- if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
1093
- $paretoFront = $workResult['paretoFront'] ?? null;
1094
- if (is_array($paretoFront) && !empty($paretoFront)) {
1095
- $bestSolution = $paretoFront[0];
1096
- $minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
1097
- for ($i = 1; $i < count($paretoFront); $i++) {
1098
- $distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
1099
- if ($distance < $minDistance) {
1100
- $minDistance = $distance;
1101
- $bestSolution = $paretoFront[$i];
1102
- }
1103
- }
1104
- if (isset($bestSolution['solution'])) {
1105
- $this->updateConfig($bestSolution['solution']);
1106
- $this->log('Useful Work auto-tuning applied successfully to live config.');
1107
- }
1108
- }
1109
- }
1110
-
1111
- $isValid = true;
1112
- // FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
1113
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
1114
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1115
- $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
1116
- $ticket = "{$expiry}:{$signature}";
1117
- }
1118
- }
1119
- }
1120
- }
1121
-
1122
- if ($isValid) {
1123
- $store->delete("secret:{$powNonce}");
1124
- $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1125
- MetricsManager::incrementCounter('challenges_solved_total');
1126
- $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
1127
-
1128
- $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
1129
- (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
1130
- ($context->getHeader('x-forwarded-proto') === 'https');
1131
- $secureOption = $isHttps || $this->isProduction;
1132
-
1133
- return [
1134
- 'action' => 'redirect',
1135
- 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
1136
- 'score' => 0.0,
1137
- 'vector' => ['challenge_solved' => 100],
1138
- 'cookie' => [
1139
- 'name' => 'pow_clearance',
1140
- 'value' => $ticket,
1141
- 'options' => [
1142
- 'httponly' => true,
1143
- 'secure' => $secureOption,
1144
- 'samesite' => 'Strict',
1145
- 'expires' => time() + ($ticketTtl / 1000),
1146
- 'path' => '/',
1147
- 'partitioned' => $secureOption,
1148
- ]
1149
- ]
1150
- ];
1151
- }
1152
- }
1153
- // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
1154
- MetricsManager::incrementCounter('challenges_failed_total');
1155
- $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
1156
- $suspicionVector['honeypotScore'] = 100.0;
1157
- }
1158
-
1159
- // 3. Vérifier un ticket existant
1160
- $hasValidTicket = false;
1161
- $powCookie = $context->cookies['pow_clearance'] ?? null;
1162
- $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1163
- $deviceId = $context->cookies['device_id'] ?? '';
1164
- $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
1165
- $allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
1166
- if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
1167
- $hasValidTicket = true;
1168
- MetricsManager::incrementCounter('tickets_valid_total');
1169
- // On ne retourne pas tout de suite pour permettre le re-challenge
1170
- // $this->log('Valid clearance ticket found');
1171
- // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
1172
- }
1173
-
1174
- // 4. Calculer le vecteur et le score de suspicion
1175
- // Résoudre l'identité et vérifier le statut "condamné"
1176
- $store = StoreManager::getStore();
1177
- $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1178
- $deviceId = $identity['deviceId'];
1179
- $deviceData = $identity['deviceData'];
1180
- if ($deviceData && ($deviceData['condemned'] ?? false)) {
1181
- $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
1182
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1183
- if ($this->dryRun) {
1184
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1185
- $decision['intendedAction'] = $decision['action'];
1186
- $decision['action'] = 'next';
1187
- unset($decision['status'], $decision['body']);
1188
- }
1189
- return $decision;
1190
- }
1191
-
1192
- $suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
1193
- $finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
1194
- $this->log('Suspicion vector and final score calculated', [
1195
- 'finalScore' => round($finalScore, 2),
1196
- 'vector' => $suspicionVector
1197
- ]);
1198
-
1199
- // Mettre à jour les métriques du sous-réseau après le calcul du score final
1200
- if ($finalScore >= ($thresholds['medium'] ?? 45)) {
1201
- RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
1202
- MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
1203
- }
1204
-
1205
- // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
1206
- $isNewDevice = $identity['newCookie'] !== null;
1207
- if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
1208
- $this->log('New device - enforcing minimum challenge score', [
1209
- 'originalScore' => round($finalScore, 2),
1210
- 'enforcedScore' => (float)$thresholds['low']
1211
- ]);
1212
- $finalScore = (float)$thresholds['low'];
1213
- }
1214
-
1215
- // Vérifier les URL pièges (après calcul du score)
1216
- $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
1217
- if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
1218
- if ($this->logger) {
1219
- $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
1220
- }
1221
- $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
1222
- $deviceData['condemned'] = true; // @phpstan-ignore-line
1223
- $store->set("device:{$deviceId}", $deviceData);
1224
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1225
- if ($this->dryRun) {
1226
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1227
- $decision['intendedAction'] = $decision['action'];
1228
- $decision['action'] = 'next';
1229
- unset($decision['status'], $decision['body']);
1230
- }
1231
- return $decision;
1232
- }
1233
-
1234
- // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
1235
- $blockThreshold = $thresholds['block'] ?? 95;
1236
- if ($finalScore >= $blockThreshold) {
1237
- if ($this->logger) {
1238
- MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
1239
- $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1240
- }
1241
-
1242
- // Federated Threat Intelligence & ZKP Synchronization
1243
- $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1244
- $parts = explode(':', $zkpProof);
1245
- if (count($parts) === 3) {
1246
- $zkpY = $parts[0];
1247
- $zkpT = $parts[1];
1248
- $zkpS = $parts[2];
1249
- // 1. Valider cryptographiquement la preuve avant de bannir/diffuser
1250
- if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
1251
- // 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
1252
- $peersKey = "fed-peers:{$zkpY}";
1253
- $reportedPeers = $store->get($peersKey) ?: [];
1254
- if (!is_array($reportedPeers)) {
1255
- $reportedPeers = [];
1256
- }
1257
- if (!in_array('local', $reportedPeers, true)) {
1258
- $reportedPeers[] = 'local';
1259
- $store->set($peersKey, $reportedPeers, 86400 * 30);
1260
- }
1261
-
1262
- $threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
1263
- if (count($reportedPeers) >= $threshold) {
1264
- $store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
1265
- }
1266
- $this->broadcastBannedZkp($zkpY);
1267
- }
1268
- }
1269
-
1270
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1271
- if ($this->dryRun) {
1272
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1273
- $decision['intendedAction'] = $decision['action'];
1274
- $decision['action'] = 'next';
1275
- unset($decision['status'], $decision['body']);
1276
- MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
1277
- }
1278
- $response = $decision;
1279
- } else {
1280
- // Logique de re-challenge
1281
- // Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
1282
- if ($powNonce && !$isChallengeSubmission) {
1283
- $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
1284
- $suspicionVector['honeypotScore'] = 100.0;
1285
- $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
1286
- $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1287
- // Apply dry run logic here as well
1288
- if ($this->dryRun) {
1289
- MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
1290
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1291
- $decision['intendedAction'] = $decision['action'];
1292
- $decision['action'] = 'next';
1293
- unset($decision['status'], $decision['body']);
1294
- }
1295
- return $decision;
1296
- }
1297
-
1298
- $highThreshold = $thresholds['high'] ?? 75;
1299
- $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket && $finalScore > 0;
1300
-
1301
- $lowThreshold = $thresholds['low'] ?? 20;
1302
- if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
1303
- if ($mustReChallenge) {
1304
- $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
1305
- }
1306
-
1307
- // --- AJOUT: Limiteur de débit (Token Bucket) ---
1308
- $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
1309
- if (!$rateLimitPassed) {
1310
- $this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
1311
- $decision = [
1312
- 'action' => 'block',
1313
- 'status' => 429,
1314
- 'body' => 'Too Many Requests',
1315
- 'score' => $finalScore,
1316
- 'vector' => $suspicionVector
1317
- ];
1318
- if ($this->dryRun) {
1319
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1320
- $decision['intendedAction'] = $decision['action'];
1321
- $decision['action'] = 'next';
1322
- unset($decision['status'], $decision['body']);
1323
- }
1324
- return $decision;
1325
- }
1326
-
1327
- $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
1328
-
1329
- if ($this->dryRun) {
1330
- MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
1331
- $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1332
- $decision['intendedAction'] = $decision['action'];
1333
- $decision['action'] = 'next';
1334
- unset($decision['status']);
1335
- return $decision;
1336
- }
1337
-
1338
- MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
1339
- $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
1340
-
1341
- $nonce = bin2hex(random_bytes(16));
1342
- $clientSecret = bin2hex(random_bytes(16));
1343
- $highThreshold = $thresholds['high'] ?? 75;
1344
-
1345
- $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
1346
- $suspicionFactor = max(0, min(1.5, $suspicionFactor));
1347
-
1348
- // --- NOUVELLE LOGIQUE uPoW ---
1349
- $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
1350
- ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
1351
- );
1352
-
1353
- // Déterminer si c'est une requête API avant de choisir le type de challenge
1354
- $isApiRequest = false;
1355
- if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
1356
- $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
1357
- }
1358
-
1359
- if ($shouldUseUsefulWork) {
1360
- $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1361
- $defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
1362
- $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1363
- $problemManager = ProblemManager::getInstance($configPath, $store);
1364
- $work = $problemManager->dispatchWork($finalScore);
1365
-
1366
- if ($work !== null) {
1367
- $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
1368
- $challengePayload = [
1369
- 'challenge' => [
1370
- 'type' => 'useful_work_task',
1371
- 'nonce' => $nonce,
1372
- 'clientSecret' => $clientSecret,
1373
- 'usefulWorkTask' => [
1374
- 'problemId' => $work['problemId'],
1375
- 'task' => $work['task']
1376
- ]
1377
- ]
1378
- ];
1379
- if ($isApiRequest) {
1380
- $decision['body'] = $challengePayload;
1381
- return $decision;
1382
- } else {
1383
- $html = '<html><body><script>';
1384
- $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}));';
1385
- $html .= '</script></body></html>';
1386
- $decision['body'] = $html;
1387
- return $decision;
1388
- }
1389
- } else {
1390
- // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
1391
- // We log it and fall through to the standard PoW challenge.
1392
- $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
1393
- $shouldUseUsefulWork = false; // Explicitly disable for this request
1394
- }
1395
- }
1396
-
1397
- // --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
1398
-
1399
- if ($this->securityConfig['enableProofOfSpace'] ?? false) {
1400
- $spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
1401
- $store->set("secret:{$nonce}", [
1402
- 'clientSecret' => $clientSecret,
1403
- 'suspicionScore' => $finalScore,
1404
- 'queries' => $spaceChallenge['queries'],
1405
- 'sizeMb' => $spaceChallenge['sizeMb'],
1406
- 'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
1407
- 'originalPath' => $context->path,
1408
- ], $this->securityConfig['challengeTtl'] ?? 300);
1409
-
1410
- if ($deviceData) {
1411
- $deviceData['lastChallengeNonce'] = $nonce;
1412
- $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1413
- }
1414
-
1415
- if ($isApiRequest) {
1416
- $decision['body'] = [
1417
- 'challenge' => [
1418
- 'type' => 'pospace',
1419
- 'nonce' => $nonce,
1420
- 'clientSecret' => $clientSecret,
1421
- 'queries' => $spaceChallenge['queries'],
1422
- 'sizeMb' => $spaceChallenge['sizeMb'],
1423
- ]
1424
- ];
1425
- } else {
1426
- $page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
1427
- $decision['body'] = $page;
1428
- }
1429
- return $decision;
1430
- }
1431
-
1432
- $cpuChallengeDetails = [
1433
- 'type' => 'cpu_target',
1434
- 'nonce' => $nonce,
1435
- 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1436
- 'path' => $context->path,
1437
- ];
1438
-
1439
- // Alignement linéaire parfait du ratio d'effort CPU/Mémoire
1440
- $memActivationFactor = $suspicionFactor;
1441
- $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
1442
-
1443
- $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1444
- $tlsSessionId = $context->tlsSessionId ?? '';
1445
- $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
1446
-
1447
- $challengeContext = [
1448
- 'clientSecret' => $clientSecret,
1449
- 'cpuTarget' => $cpuChallengeDetails['target'],
1450
- 'suspicionScore' => $finalScore,
1451
- 'fingerprint' => $originalFingerprint,
1452
- 'memDifficulty' => $memDifficulty,
1453
- 'baseBlock' => $baseBlock,
1454
- 'originalPath' => $context->path,
1455
- ];
1456
-
1457
- $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1458
-
1459
- // Associer le nonce au device pour la vérification des URL pièges
1460
- if ($deviceData) {
1461
- $deviceData['lastChallengeNonce'] = $nonce;
1462
- $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1463
- }
1464
-
1465
- $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1466
- $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1467
-
1468
- if ($this->logger) {
1469
- $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1470
- }
1471
-
1472
- // Pour les API, retourner un challenge JSON
1473
- if ($isApiRequest) {
1474
- $challengePayload = [
1475
- 'challenge' => [
1476
- 'type' => 'cpu_mem',
1477
- 'nonce' => $nonce,
1478
- 'clientSecret' => $clientSecret,
1479
- 'cpuTarget' => $cpuChallengeDetails['target'],
1480
- 'memDifficulty' => $memDifficulty,
1481
- 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
1482
- ]
1483
- ];
1484
- $decision['body'] = $challengePayload;
1485
- } else {
1486
- // Pour les navigateurs, retourner une page HTML
1487
- $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1488
- $cpuChallengeDetails, $memDifficulty, $clientSecret,
1489
- $this->securityConfig, $trapUrls, $originalFingerprint
1490
- );
1491
- $decision['body'] = $pageBody;
1492
- }
1493
- $response = $decision;
1494
- } elseif ($hasValidTicket) {
1495
- // Si on arrive ici avec un ticket valide et un score bas, on autorise
1496
- MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1497
- $this->log('Valid clearance ticket found and score is low - allowing request');
1498
- $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
1499
- } else {
1500
- // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
1501
- MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1502
- $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
1503
- if ($this->logger) {
1504
- MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
1505
- $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1506
- }
1507
- $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1508
- }
1509
- }
1510
-
1511
- // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
1512
- if (isset($context->newCookieForResponse)) {
1513
- $response['newCookieForResponse'] = $context->newCookieForResponse;
1514
- }
1515
-
1516
- return $response;
1517
- }
1518
-
1519
- /**
1520
- * Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
1521
- */
1522
- private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
1523
- {
1524
- if (empty($operationType) || empty($operationName)) {
1525
- return false;
1526
- }
1527
-
1528
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1529
- $graphqlRule = null;
1530
- foreach ($whitelistRules as $rule) {
1531
- if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
1532
- $graphqlRule = $rule;
1533
- break;
1534
- }
1535
- }
1536
-
1537
- if (empty($graphqlRule['entries'])) {
1538
- return false;
1539
- }
1540
-
1541
- foreach ($graphqlRule['entries'] as $entry) {
1542
- [$entryType, $entryName] = explode(':', $entry, 2);
1543
- if ($entryType !== $operationType) continue;
1544
-
1545
- if ($entryName === $operationName || $entryName === '*') return true;
1546
-
1547
- if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1548
- }
1549
-
1550
- return false;
1551
- }
1552
-
1553
-
1554
- private function broadcastBannedZkp(string $zkpY): void
1555
- {
1556
- $peers = $this->securityConfig['federatedPeers'] ?? [];
1557
- if (empty($peers)) return;
1558
-
1559
- $timestamp = (int)(microtime(true) * 1000);
1560
- $msg = "{$timestamp}:{$zkpY}";
1561
-
1562
- $signature = '';
1563
- $isAsymmetric = false;
1564
-
1565
- $privateKey = Env::get('ED25519_PRIVATE_KEY');
1566
- if ($privateKey) {
1567
- try {
1568
- $cleanKey = str_replace('\n', "\n", $privateKey);
1569
- $pkeyObj = openssl_pkey_get_private($cleanKey);
1570
- if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
1571
- $signature = bin2hex($sigBytes);
1572
- $isAsymmetric = true;
1573
- }
1574
- } catch (\Throwable $e) {
1575
- error_log('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
1576
- }
1577
- }
1578
-
1579
- if (!$isAsymmetric) {
1580
- $secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
1581
- $signature = hash_hmac('sha256', $msg, $secret);
1582
- }
1583
-
1584
- foreach ($peers as $peerUrl) {
1585
- $this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
1586
- 'zkpY' => $zkpY,
1587
- 'signature' => $isAsymmetric ? '' : $signature,
1588
- 'signature_ed25519' => $isAsymmetric ? $signature : '',
1589
- 'timestamp' => $timestamp
1590
- ]);
1591
- }
1592
- }
1593
-
1594
- private function asyncPost(string $url, array $params): void
1595
- {
1596
- $parts = parse_url($url);
1597
- if ($parts === false) return;
1598
-
1599
- $host = $parts['host'];
1600
- $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
1601
- $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
1602
- $scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
1603
-
1604
- $postData = json_encode($params);
1605
-
1606
- $fp = @stream_socket_client(
1607
- "{$scheme}{$host}:{$port}",
1608
- $errno,
1609
- $errstr,
1610
- 0.5,
1611
- STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
1612
- );
1613
-
1614
- if ($fp) {
1615
- stream_set_blocking($fp, false);
1616
- $out = "POST {$path} HTTP/1.1\r\n";
1617
- $out .= "Host: {$host}\r\n";
1618
- $out .= "Content-Type: application/json\r\n";
1619
- $out .= "Content-Length: " . strlen($postData) . "\r\n";
1620
- $out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
1621
- $out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
1622
- $out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
1623
- $out .= "Connection: Close\r\n\r\n";
1624
- $out .= $postData;
1625
-
1626
- @fwrite($fp, $out);
1627
- @fclose($fp);
1628
- }
1629
- }
1630
-
1631
- /**
1632
- * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
1633
- * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
1634
- */
1635
- private function verifyWhitelistedBot(RequestContext $context): bool
1636
- {
1637
- $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1638
- $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
1639
- if (empty($botRules)) {
1640
- return false;
1641
- }
1642
-
1643
- $userAgent = $context->getHeader('user-agent') ?? '';
1644
- $matchedRule = null;
1645
- foreach ($botRules as $rule) {
1646
- if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
1647
- $matchedRule = $rule;
1648
- break;
1649
- }
1650
- }
1651
- if ($matchedRule === null) {
1652
- return false;
1653
- }
1654
-
1655
- $store = StoreManager::getStore();
1656
- $cacheKey = "ip-whitelist:{$context->clientIp}";
1657
- $cachedStatus = $store->get($cacheKey);
1658
-
1659
- if ($cachedStatus === 'verified') return true;
1660
- if ($cachedStatus === 'failed') return false;
1661
-
1662
- if (!self::canAttemptDns()) {
1663
- return false;
1664
- }
1665
-
1666
- try {
1667
- // 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
1668
- $revName = self::getReverseDnsName($context->client_ip);
1669
- if (!$revName) {
1670
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1671
- return false;
1672
- }
1673
- $hostnames = self::dnsResolveUdp($revName, 'PTR', 0.5);
1674
- if ($hostnames === null || empty($hostnames)) {
1675
- self::recordDnsFailure();
1676
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1677
- return false;
1678
- }
1679
-
1680
- $validHostname = null;
1681
- foreach ($hostnames as $hostname) {
1682
- if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1683
- $validHostname = $hostname;
1684
- break;
1685
- }
1686
- }
1687
-
1688
- if ($validHostname === null) {
1689
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1690
- return false;
1691
- }
1692
-
1693
- // 2. Forward DNS lookup with strict 500ms timeout
1694
- $ips = [];
1695
- $resolvedA = self::dnsResolveUdp($validHostname, 'A', 0.5);
1696
- if ($resolvedA === null) {
1697
- self::recordDnsFailure();
1698
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1699
- return false;
1700
- }
1701
- $ips = array_merge($ips, $resolvedA);
1702
-
1703
- $resolvedAaaa = self::dnsResolveUdp($validHostname, 'AAAA', 0.5);
1704
- if ($resolvedAaaa === null) {
1705
- self::recordDnsFailure();
1706
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1707
- return false;
1708
- }
1709
- $ips = array_merge($ips, $resolvedAaaa);
1710
-
1711
- if (in_array($context->client_ip, $ips, true)) {
1712
- self::recordDnsSuccess();
1713
- $store->set($cacheKey, 'verified', 86400);
1714
- return true;
1715
- }
1716
- } catch (\Exception $e) {
1717
- self::recordDnsFailure();
1718
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1719
- return false;
1720
- }
1721
-
1722
- $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1723
- return false;
1724
- }
1725
-
1726
- /**
1727
- * @internal For testing purposes only.
1728
- */
1729
- public function getProblems(): array
1730
- {
1731
- $problemManager = ProblemManager::getInstance();
1732
- return $problemManager->getProblems();
1733
- }
1734
- private static function decodeCBOR(string $str, int &$offset = 0)
1735
- {
1736
- if ($offset >= strlen($str)) throw new \Exception("End of CBOR");
1737
- $initial = ord($str[$offset++]);
1738
- $major = $initial >> 5;
1739
- $val = $initial & 0x1f;
1740
-
1741
- $readInt = function (int $val, string $str, int &$offset) {
1742
- if ($val < 24) return $val;
1743
- if ($val === 24) return ord($str[$offset++]);
1744
- if ($val === 25) {
1745
- $b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
1746
- return ($b1 << 8) | $b2;
1747
- }
1748
- if ($val === 26) {
1749
- $b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
1750
- $b3 = ord($str[$offset++]); $b4 = ord($str[$offset++]);
1751
- return ($b1 << 24) | ($b2 << 16) | ($b3 << 8) | $b4;
1752
- }
1753
- throw new \Exception("Unsupported integer size: " . $val);
1754
- };
1755
-
1756
- if ($major === 0) {
1757
- return $readInt($val, $str, $offset);
1758
- } elseif ($major === 1) {
1759
- return -1 - $readInt($val, $str, $offset);
1760
- } elseif ($major === 2 || $major === 3) {
1761
- $len = $readInt($val, $str, $offset);
1762
- $bytes = substr($str, $offset, $len);
1763
- $offset += $len;
1764
- return $bytes;
1765
- } elseif ($major === 4) {
1766
- $len = $readInt($val, $str, $offset);
1767
- $arr = [];
1768
- for ($i = 0; $i < $len; $i++) {
1769
- $arr[] = self::decodeCBOR($str, $offset);
1770
- }
1771
- return $arr;
1772
- } elseif ($major === 5) {
1773
- $len = $readInt($val, $str, $offset);
1774
- $map = [];
1775
- for ($i = 0; $i < $len; $i++) {
1776
- $k = self::decodeCBOR($str, $offset);
1777
- $v = self::decodeCBOR($str, $offset);
1778
- $map[$k] = $v;
1779
- }
1780
- return $map;
1781
- }
1782
- return null;
1783
- }
1784
-
1785
- private static function getTrustedHardwareRoots(): array
1786
- {
1787
- return [
1788
- "-----BEGIN CERTIFICATE-----\n" .
1789
- "MIIDHzCCAfegAwIBAgIJANCvWjvF+2O6MA0GCSqGSIb3DQEBCwUAMC0xKzApBgNV\n" .
1790
- "BAMTIll1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMB4XDTE0MDgwNDAwMDAwMFox\n" .
1791
- "TSUxSDBGBgNVBAMMT1l1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMSowKAYDVQQK\n" .
1792
- "EyFZdWJpY28gQUIxDzANBgNVBAcTBVN0b2NraG9sbTELMAkGA1UEBhMCU0UwggEi\n" .
1793
- "MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6XW0d87g+N6kGgSgC/H9UfA2p\n" .
1794
- "-----END CERTIFICATE-----",
1795
- "-----BEGIN CERTIFICATE-----\n" .
1796
- "MIIB1DCCAXWgAwIBAgIEUI70WjAKBggqhkjOPQQDAjArMSkwJwYDVQQDEyBGSURP\n" .
1797
- "IEFsbGlhbmNlIFJvb3QgQ0EgKFRlc3QpMB4XDTE0MDgxODA4MzA0NVoXDTM5MDgx\n" .
1798
- "ODA4MzA0NVowKzEpMCcGA1UEAxMgRklETyBBbGxpYW5jZSBSb290IENBIChUZXN0\n" .
1799
- "KTB2MBAGByqGSM49AgEGBSuBBAAiA2IABFv81Jm9M7AehfOIdpCH567gP0yqS40m\n" +
1800
- "aN0j1a8n152G7n/nUf7J0j9F4pL9J2w1X8hN1N8f9Y3G9w8L29/m7/zX3O3n2e7/\n" .
1801
- "g==\n" .
1802
- "-----END CERTIFICATE-----"
1803
- ];
1804
- }
1805
-
1806
- private function verifyWebAuthnHardwareAnchor(array $anchor, array &$deviceData): bool
1807
- {
1808
- if (empty($anchor['type'])) return false;
1809
-
1810
- try {
1811
- $clientDataHash = hash('sha256', base64_decode($anchor['clientDataJSON']), true);
1812
-
1813
- if ($anchor['type'] === 'registration') {
1814
- if (empty($anchor['publicKey']) || empty($anchor['credentialId']) || empty($anchor['attestationObject'])) {
1815
- return false;
1816
- }
1817
-
1818
- $attestationBytes = base64_decode($anchor['attestationObject']);
1819
- $offset = 0;
1820
- $decoded = self::decodeCBOR($attestationBytes, $offset);
1821
-
1822
- if (!$decoded || empty($decoded['fmt']) || empty($decoded['attStmt'])) {
1823
- return false;
1824
- }
1825
-
1826
- $fmt = $decoded['fmt'];
1827
- $attStmt = $decoded['attStmt'];
1828
-
1829
- if ($fmt !== 'none') {
1830
- if (empty($attStmt['x5c']) || !is_array($attStmt['x5c'])) {
1831
- return false;
1832
- }
1833
-
1834
- $pems = [];
1835
- foreach ($attStmt['x5c'] as $der) {
1836
- $pems[] = "-----BEGIN CERTIFICATE-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END CERTIFICATE-----";
1837
- }
1838
-
1839
- for ($i = 0; $i < count($pems) - 1; $i++) {
1840
- if (openssl_x509_verify($pems[$i], $pems[$i + 1]) !== 1) {
1841
- return false;
1842
- }
1843
- }
1844
-
1845
- $rootPem = $pems[count($pems) - 1];
1846
- $trusted = false;
1847
- $trustedRoots = self::getTrustedHardwareRoots();
1848
- foreach ($trustedRoots as $trustedRootPem) {
1849
- if (openssl_x509_verify($rootPem, $trustedRootPem) === 1 || md5($rootPem) === md5($trustedRootPem)) {
1850
- $trusted = true;
1851
- break;
1852
- }
1853
- }
1854
- if (!$trusted) {
1855
- return false;
1856
- }
1857
- }
1858
-
1859
- $deviceData['webauthnPublicKey'] = $anchor['publicKey'];
1860
- $deviceData['webauthnCredentialId'] = $anchor['credentialId'];
1861
- return true;
1862
- } elseif ($anchor['type'] === 'assertion') {
1863
- $storedPublicKeyPem = $deviceData['webauthnPublicKey'] ?? null;
1864
- if (!$storedPublicKeyPem || ($deviceData['webauthnCredentialId'] ?? '') !== $anchor['credentialId']) {
1865
- return false;
1866
- }
1867
-
1868
- $verifyBuffer = base64_decode($anchor['authenticatorData']) . $clientDataHash;
1869
- $publicKey = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----\n" . chunk_split($storedPublicKeyPem, 64, "\n") . "-----END PUBLIC KEY-----");
1870
- if (!$publicKey) return false;
1871
-
1872
- return openssl_verify($verifyBuffer, base64_decode($anchor['signature']), $publicKey, OPENSSL_ALGO_SHA256) === 1;
1873
- }
1874
- } catch (\Throwable $e) {
1875
- error_log('[WebAuthn-Server] PHP verification failed: ' . $e->getMessage());
1876
- }
1877
- return false;
1878
- }
1879
- }
1
+ <?php
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
+
35
+ private static array $dnsCircuitBreaker = [
36
+ 'state' => 'CLOSED',
37
+ 'failureCount' => 0,
38
+ 'lastStateChange' => 0,
39
+ 'threshold' => 5,
40
+ 'cooldown' => 30, // seconds
41
+ ];
42
+
43
+ private static function recordDnsSuccess(): void
44
+ {
45
+ self::$dnsCircuitBreaker['failureCount'] = 0;
46
+ self::$dnsCircuitBreaker['state'] = 'CLOSED';
47
+ }
48
+
49
+ private static function recordDnsFailure(): void
50
+ {
51
+ self::$dnsCircuitBreaker['failureCount']++;
52
+ if (self::$dnsCircuitBreaker['failureCount'] >= self::$dnsCircuitBreaker['threshold']) {
53
+ self::$dnsCircuitBreaker['state'] = 'OPEN';
54
+ self::$dnsCircuitBreaker['lastStateChange'] = microtime(true);
55
+ }
56
+ }
57
+
58
+ private static function canAttemptDns(): bool
59
+ {
60
+ if (self::$dnsCircuitBreaker['state'] === 'CLOSED') {
61
+ return true;
62
+ }
63
+ if (self::$dnsCircuitBreaker['state'] === 'OPEN') {
64
+ if (microtime(true) - self::$dnsCircuitBreaker['lastStateChange'] >= self::$dnsCircuitBreaker['cooldown']) {
65
+ self::$dnsCircuitBreaker['state'] = 'HALF-OPEN';
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ return true; // HALF-OPEN
71
+ }
72
+
73
+ private static function getReverseDnsName(string $ip): ?string
74
+ {
75
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
76
+ return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
77
+ } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
78
+ $hex = bin2hex((string)inet_pton($ip));
79
+ return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
80
+ }
81
+ return null;
82
+ }
83
+
84
+ private static function dnsResolveUdp(string $query, string $type, float $timeout = 0.5): ?array
85
+ {
86
+ $dnsServer = '8.8.8.8';
87
+ $port = 53;
88
+ $fp = @stream_socket_client("udp://$dnsServer:$port", $errno, $errstr, $timeout);
89
+ if (!$fp) return null;
90
+ stream_set_timeout($fp, 0, (int)($timeout * 1000000));
91
+
92
+ $id = rand(10000, 65000);
93
+ $header = pack('n6', $id, 0x0100, 1, 0, 0, 0);
94
+
95
+ $qType = ($type === 'PTR') ? 12 : (($type === 'AAAA') ? 28 : 1);
96
+ $parts = explode('.', $query);
97
+ $qName = '';
98
+ foreach ($parts as $part) {
99
+ $qName .= chr(strlen($part)) . $part;
100
+ }
101
+ $qName .= chr(0);
102
+ $question = $qName . pack('n2', $qType, 1);
103
+
104
+ if (!@fwrite($fp, $header . $question)) {
105
+ fclose($fp);
106
+ return null;
107
+ }
108
+
109
+ $response = @fread($fp, 512);
110
+ fclose($fp);
111
+ if (!$response || strlen($response) < 12) return null;
112
+
113
+ $resHeader = unpack('n6', substr($response, 0, 12));
114
+ if ($resHeader[1] !== $id) return null;
115
+
116
+ $answersCount = $resHeader[4];
117
+ if ($answersCount <= 0) return [];
118
+
119
+ $offset = 12 + strlen($question);
120
+ $results = [];
121
+ for ($i = 0; $i < $answersCount; $i++) {
122
+ if ($offset + 12 > strlen($response)) break;
123
+ if ((ord($response[$offset]) & 0xc0) === 0xc0) {
124
+ $offset += 2;
125
+ } else {
126
+ while ($offset < strlen($response) && ord($response[$offset]) !== 0) {
127
+ $offset += 1 + ord($response[$offset]);
128
+ }
129
+ $offset++;
130
+ }
131
+ if ($offset + 10 > strlen($response)) break;
132
+ $typeAndClass = unpack('n2type_class/Nttl/nlen', substr($response, $offset, 10));
133
+ $rType = $typeAndClass['type_class1'];
134
+ $rLen = $typeAndClass['len'];
135
+ $offset += 10;
136
+ if ($offset + $rLen > strlen($response)) break;
137
+ $rData = substr($response, $offset, $rLen);
138
+ $offset += $rLen;
139
+
140
+ if ($rType === 12) {
141
+ $ptrName = '';
142
+ $p = 0;
143
+ while ($p < strlen($rData)) {
144
+ $l = ord($rData[$p]);
145
+ if ($l === 0) break;
146
+ if (($l & 0xc0) === 0xc0) {
147
+ break;
148
+ }
149
+ $ptrName .= substr($rData, $p + 1, $l) . '.';
150
+ $p += 1 + $l;
151
+ }
152
+ $results[] = rtrim($ptrName, '.');
153
+ } elseif ($rType === 1) {
154
+ if ($rLen === 4) {
155
+ $results[] = long2ip(unpack('N', $rData)[1]);
156
+ }
157
+ } elseif ($rType === 28) {
158
+ if ($rLen === 16) {
159
+ $results[] = inet_ntop($rData);
160
+ }
161
+ }
162
+ }
163
+ return $results;
164
+ }
165
+
166
+ public function __construct(array $securityConfig)
167
+ {
168
+ $this->isProduction = Env::get('APP_ENV') === 'production';
169
+
170
+ // Dynamically bind Ed25519 keys if passed via config
171
+ if (isset($securityConfig['ed25519_private_key'])) {
172
+ Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
173
+ }
174
+ if (isset($securityConfig['ed25519_public_key'])) {
175
+ Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
176
+ }
177
+
178
+ // Génération et persistance automatique de la paire de clés Ed25519
179
+ $useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
180
+ $envPrivate = Env::get('ED25519_PRIVATE_KEY');
181
+ if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
182
+ $configDir = dirname(__DIR__, 1) . '/config';
183
+ $persistentKeyPath = $configDir . '/ed25519_key.json';
184
+
185
+ if (file_exists($persistentKeyPath)) {
186
+ try {
187
+ $keys = json_decode(file_get_contents($persistentKeyPath), true);
188
+ if (isset($keys['privateKey'], $keys['publicKey'])) {
189
+ Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
190
+ Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
191
+ }
192
+ } catch (\Throwable $e) {
193
+ error_log('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
194
+ }
195
+ } else {
196
+ try {
197
+ if (defined('OPENSSL_KEYTYPE_ED25519')) {
198
+ $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
199
+ if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
200
+ $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
201
+ $publicKeyPem = $details['key']; // @phpstan-ignore-line
202
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
203
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
204
+ // Also update $_ENV and $_SERVER for consistency
205
+ if (!is_dir($configDir)) {
206
+ mkdir($configDir, 0777, true);
207
+ }
208
+ file_put_contents($persistentKeyPath, json_encode([
209
+ 'privateKey' => $privateKeyPem,
210
+ 'publicKey' => $publicKeyPem
211
+ ], JSON_PRETTY_PRINT));
212
+ }
213
+ }
214
+ } catch (\Throwable $e) {
215
+ error_log('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
216
+ }
217
+ }
218
+ }
219
+
220
+ // Auto-load optimized config if autotuning savePath is specified
221
+ if (isset($securityConfig['autotuning']['savePath'])) {
222
+ $savePath = $securityConfig['autotuning']['savePath'];
223
+ if (file_exists($savePath)) {
224
+ try {
225
+ $savedConfig = json_decode(file_get_contents($savePath), true);
226
+ if (json_last_error() === JSON_ERROR_NONE && is_array($savedConfig)) {
227
+ $securityConfig = SecurityProfiles::deepMerge($securityConfig, $savedConfig);
228
+ }
229
+ } catch (\Throwable $e) {
230
+ error_log("[FingerprintEngine] Failed to auto-load optimized config from {$savePath}: " . $e->getMessage());
231
+ }
232
+ }
233
+ }
234
+ $this->securityConfig = $securityConfig;
235
+ $this->verbose = $securityConfig['verbose'] ?? false;
236
+ $this->allowlist = $this->buildAllowlist();
237
+ $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
238
+ $this->dryRun = $securityConfig['dryRun'] ?? false;
239
+ $this->validateConfig($securityConfig);
240
+ if ($this->securityConfig['reset'] ?? false) {
241
+ $this->resetStore();
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
247
+ * sans nécessiter de redémarrage.
248
+ *
249
+ * @param array $newConfig La nouvelle configuration (partielle ou complète).
250
+ */
251
+ public function updateConfig(array $newConfig): void
252
+ {
253
+ $this->validateConfig($newConfig);
254
+ $this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
255
+ $this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
256
+ }
257
+
258
+ /**
259
+ * Réinitialise le store de persistance actif.
260
+ */
261
+ public function resetStore(): void
262
+ {
263
+ $store = StoreManager::getStore();
264
+ if (method_exists($store, 'clear')) {
265
+ $store->clear();
266
+ }
267
+ $this->log('Store has been reset/cleared.');
268
+ }
269
+
270
+ private function validateConfig(array $config): void
271
+ {
272
+ if (empty($config)) {
273
+ $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
274
+ return;
275
+ }
276
+
277
+ $knownKeys = [
278
+ 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
279
+ 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
280
+ 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
281
+ 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
282
+ 'similarityThreshold', 'summary', 'description',
283
+ 'ed25519_private_key', 'ed25519_public_key',
284
+ 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret', 'reset'
285
+ ];
286
+
287
+ if (empty($config['weights'])) {
288
+ $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
289
+ }
290
+ if (empty($config['thresholds'])) {
291
+ $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
292
+ }
293
+
294
+ foreach (array_keys($config) as $key) {
295
+ if (!in_array($key, $knownKeys, true)) {
296
+ $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
297
+ }
298
+ }
299
+ }
300
+ private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
301
+ {
302
+ $configDir = dirname(__DIR__, 2) . '/config';
303
+ $filePath = $configDir . '/' . $filename;
304
+ if (file_exists($filePath)) {
305
+ try {
306
+ $content = file_get_contents($filePath);
307
+ if ($content !== false) {
308
+ $decoded = json_decode($content, true);
309
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
310
+ return $decoded;
311
+ }
312
+ }
313
+ } catch (\Throwable $e) {
314
+ error_log("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
315
+ }
316
+ }
317
+ return $fallbackEntries;
318
+ }
319
+
320
+ public static function googlebot_whitelist(): array
321
+ {
322
+ if (self::$googlebotEntries === null) {
323
+ self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
324
+ "2001:4860:4801:10::/64",
325
+ "2001:4860:4801:11::/64",
326
+ "2001:4860:4801:12::/64",
327
+ "66.249.79.64"
328
+ ]);
329
+ }
330
+ return [
331
+ 'type' => 'allowlist',
332
+ 'entries' => self::$googlebotEntries
333
+ ];
334
+ }
335
+
336
+ public static function yandex_whitelist(): array
337
+ {
338
+ if (self::$yandexEntries === null) {
339
+ self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
340
+ "2a02:6b8::/29",
341
+ "5.45.192.0/18",
342
+ "213.180.192.0/19"
343
+ ]);
344
+ }
345
+ return [
346
+ 'type' => 'allowlist',
347
+ 'entries' => self::$yandexEntries
348
+ ];
349
+ }
350
+
351
+ public static function bingbot_whitelist(): array
352
+ {
353
+ if (self::$bingbotEntries === null) {
354
+ self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
355
+ "157.55.39.0/24",
356
+ "207.46.13.0/24",
357
+ "40.77.178.0/23"
358
+ ]);
359
+ }
360
+ return [
361
+ 'type' => 'allowlist',
362
+ 'entries' => self::$bingbotEntries
363
+ ];
364
+ }
365
+
366
+ public static function default_whitelist(): array
367
+ {
368
+ return [
369
+ self::googlebot_whitelist(),
370
+ self::bingbot_whitelist(),
371
+ self::yandex_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
+ error_log($logMessage);
407
+ }
408
+ }
409
+ }
410
+
411
+ public function calculateFinalScore(array $suspicionVector): float
412
+ {
413
+ $weights = $this->securityConfig['weights'] ?? [];
414
+ if (empty($weights)) {
415
+ return 0.0;
416
+ }
417
+
418
+ $score = 0.0;
419
+ foreach ($weights as $key => $weight) {
420
+ $score += ($suspicionVector[$key] ?? 0) * $weight;
421
+ }
422
+
423
+ return min(100.0, $score);
424
+ }
425
+
426
+ private function buildAllowlist(): BlockList
427
+ {
428
+ $blockList = new BlockList();
429
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
430
+
431
+ $allowlistRule = null;
432
+ foreach ($whitelistRules as $rule) {
433
+ if (($rule['type'] ?? '') === 'allowlist') {
434
+ $allowlistRule = $rule;
435
+ break;
436
+ }
437
+ }
438
+
439
+ if (empty($allowlistRule['entries'])) {
440
+ return $blockList;
441
+ }
442
+
443
+ foreach ($allowlistRule['entries'] as $entry) {
444
+ $blockList->add($entry);
445
+ }
446
+ return $blockList;
447
+ }
448
+
449
+ private function isIpInAllowlist(string $clientIp): bool
450
+ {
451
+ return $this->allowlist->check($clientIp);
452
+ }
453
+
454
+ private function isPathInAllowlist(string $requestPath): bool
455
+ {
456
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
457
+ $pathAllowlistRule = null;
458
+ foreach ($whitelistRules as $rule) {
459
+ if (($rule['type'] ?? '') === 'path_allowlist') {
460
+ $pathAllowlistRule = $rule;
461
+ break;
462
+ }
463
+ }
464
+
465
+ if (empty($pathAllowlistRule['entries'])) {
466
+ return false;
467
+ }
468
+
469
+ foreach ($pathAllowlistRule['entries'] as $entry) {
470
+ if (str_ends_with($entry, '*')) {
471
+ $base = substr($entry, 0, -1);
472
+ if (str_starts_with($requestPath, $base)) {
473
+ return true;
474
+ }
475
+ } elseif ($requestPath === $entry) {
476
+ return true;
477
+ }
478
+ }
479
+
480
+ return false;
481
+ }
482
+
483
+ private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
484
+ {
485
+ if (empty($requestHost)) {
486
+ return false;
487
+ }
488
+
489
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
490
+ $hostPathRule = null;
491
+ foreach ($whitelistRules as $rule) {
492
+ if (($rule['type'] ?? '') === 'host_path_allowlist') {
493
+ $hostPathRule = $rule;
494
+ break;
495
+ }
496
+ }
497
+
498
+ if (empty($hostPathRule['entries'])) {
499
+ return false;
500
+ }
501
+
502
+ foreach ($hostPathRule['entries'] as $entry) {
503
+ if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
504
+ return true;
505
+ }
506
+ }
507
+ return false;
508
+ }
509
+
510
+ /**
511
+ * Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
512
+ */
513
+ private function checkAllowlists(RequestContext $context): bool
514
+ {
515
+ $whitelisted = false;
516
+ $type = '';
517
+
518
+ if ($this->isIpInAllowlist($context->clientIp)) {
519
+ $whitelisted = true;
520
+ $type = 'allowlist';
521
+ } elseif ($this->isPathInAllowlist($context->path)) {
522
+ $whitelisted = true;
523
+ $type = 'path_allowlist';
524
+ } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
525
+ $whitelisted = true;
526
+ $type = 'host_path_allowlist';
527
+ } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
528
+ $whitelisted = true;
529
+ $type = 'graphql_operation_allowlist';
530
+ } elseif ($this->verifyWhitelistedBot($context)) {
531
+ $whitelisted = true;
532
+ $type = 'bot';
533
+ }
534
+
535
+ if ($whitelisted) {
536
+ $filterWhitelist = $this->securityConfig['filterWhitelist'] ?? false;
537
+ $bypassWhitelist = false;
538
+ if ($filterWhitelist === true) {
539
+ $bypassWhitelist = $this->hasCertainAttack($context);
540
+ } elseif (is_numeric($filterWhitelist)) {
541
+ if ($context->preCalculatedScore === null) {
542
+ $suspicionVector = [];
543
+ $context->preCalculatedVector = $this->getSuspicionVector($context, $suspicionVector);
544
+ $context->preCalculatedScore = $this->calculateFinalScore($context->preCalculatedVector);
545
+ }
546
+ if ($context->preCalculatedScore > $filterWhitelist) {
547
+ $bypassWhitelist = true;
548
+ }
549
+ }
550
+
551
+ if ($bypassWhitelist) {
552
+ $this->log('Whitelisted request exceeds filter threshold - bypassing whitelist bypass', ['clientIp' => $context->clientIp, 'path' => $context->path]);
553
+ return false;
554
+ }
555
+ $this->log("IP/Path in allowlist ({$type}) - allowing request", ['clientIp' => $context->clientIp, 'path' => $context->path]);
556
+ return true;
557
+ }
558
+
559
+ return false;
560
+ }
561
+
562
+ /**
563
+ * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
564
+ */
565
+ private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
566
+ {
567
+ if ($context->resolvedIdentity !== null) {
568
+ return $context->resolvedIdentity;
569
+ }
570
+ $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
571
+ $store = StoreManager::getStore();
572
+ $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
573
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
574
+ $pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
575
+
576
+ $deviceId = $existingDeviceId;
577
+ $deviceData = null;
578
+ $newCookie = null;
579
+
580
+ // Cookieless Identity Tracking: Attempt to restore device ID using TLS session resume ID
581
+ $tlsSessionId = $context->tlsSessionId;
582
+ if (!$deviceId && $tlsSessionId) {
583
+ $resumedDeviceId = $store->get("tls-session:{$tlsSessionId}");
584
+ if ($resumedDeviceId) {
585
+ $deviceId = $resumedDeviceId;
586
+ $this->log('Identity resumed via TLS Session ID', ['deviceId' => $deviceId, 'tlsSessionId' => $tlsSessionId]);
587
+ }
588
+ }
589
+
590
+ if ($deviceId) {
591
+ $deviceData = $store->get("device:{$deviceId}");
592
+ }
593
+
594
+ if ($deviceData === null) {
595
+ // Nouvel utilisateur ou cookie perdu/invalide
596
+ $pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
597
+ if ($pendingDeviceId && !$existingDeviceId) {
598
+ // La pénalité est maintenant ajoutée directement au vecteur de suspicion.
599
+ $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
600
+ $suspicionVector['cookieDroppingScore'] = 100.0;
601
+ }
602
+
603
+ $deviceId = bin2hex(random_bytes(16)); // UUID-like
604
+
605
+ $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
606
+ (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
607
+ ($context->getHeader('x-forwarded-proto') === 'https');
608
+ $secureOption = $isHttps || $this->isProduction;
609
+
610
+ // Préparer le cookie à envoyer
611
+ $newCookie = [
612
+ 'name' => 'device_id',
613
+ 'value' => $deviceId,
614
+ 'options' => [
615
+ 'httponly' => true,
616
+ 'secure' => $secureOption,
617
+ 'partitioned' => $secureOption,
618
+ 'samesite' => 'Strict',
619
+ 'path' => '/',
620
+ ]
621
+ ];
622
+ if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
623
+ $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
624
+ }
625
+
626
+ $store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
627
+
628
+ $deviceData = [
629
+ 'initialDeviceHash' => $currentDeviceHash,
630
+ 'ips' => [$context->clientIp],
631
+ 'requestHistory' => [],
632
+ 'lastUpdate' => time() * 1000,
633
+ 'lastFpHash' => $currentDeviceHash,
634
+ 'lastChangeTimestamp' => 0,
635
+ 'rapidChangeCount' => 0,
636
+ 'highScoreCount' => 0,
637
+ 'lastHighScoreTimestamp' => 0,
638
+ ];
639
+ } else {
640
+ // S'assurer que 'ips' est un tableau pour les opérations suivantes.
641
+ if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
642
+ $deviceData['ips'] = [];
643
+ }
644
+ }
645
+
646
+ // Bind the current TLS session ID to the device ID
647
+ if ($deviceId && $tlsSessionId) {
648
+ $store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
649
+ }
650
+
651
+ $context->resolvedIdentity = [
652
+ 'deviceId' => $deviceId,
653
+ 'deviceData' => $deviceData,
654
+ 'newCookie' => $newCookie,
655
+ ];
656
+ return $context->resolvedIdentity;
657
+ }
658
+
659
+ /**
660
+ * @return array<string, float>
661
+ */
662
+ public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
663
+ {
664
+ $store = StoreManager::getStore();
665
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
666
+ $deviceData = $identity['deviceData'];
667
+ $deviceId = $identity['deviceId'];
668
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
669
+ $suspicionVector['honeypotScore'] = 100;
670
+ return $suspicionVector;
671
+ }
672
+
673
+ // Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
674
+ // pour que le code appelant puisse le gérer.
675
+ if ($identity['newCookie']) {
676
+ // Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
677
+ $context->newCookieForResponse = $identity['newCookie'];
678
+ }
679
+
680
+ // Nettoyage périodique des données de l'appareil
681
+ if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
682
+ $deviceData['ips'] = [];
683
+ $deviceData['rapidChangeCount'] = 0;
684
+ $deviceData['ipTimes'] = [];
685
+ }
686
+ $deviceData['lastUpdate'] = time() * 1000;
687
+
688
+ // --- Calcul des différents scores de suspicion ---
689
+
690
+ // Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
691
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
692
+ $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
693
+ $inconsistencyScore = min(100.0, max(0.0, (1 - $consistencyScore) * 200));
694
+ if ($consistencyScore < ($this->securityConfig['similarityThreshold'] ?? 0.7)) {
695
+ $inconsistencyScore = 100.0;
696
+ }
697
+
698
+ $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
699
+
700
+ // Score des anomalies d'en-têtes
701
+ $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
702
+
703
+ // Score de spoofing TLS
704
+ $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
705
+ $tlsSpoofingScore = (float)($tlsSpoofing['tlsSpoofingScore'] ?? 0.0);
706
+
707
+ // Advanced JA4 TLS Inconsistency checks
708
+ $ja4 = $context->getHeader('x-ja4-hash');
709
+ if ($ja4) {
710
+ $spoofedJa4s = [
711
+ 't13d1516h2_8daaf6152771_390237aa04be', // Chrome classique (curl-impersonate / tls-client)
712
+ 't13d1413h2_bc66258908f0_bc2531da1615', // Firefox statique (curl-impersonate-ff / curl_cffi)
713
+ 't13d1515h2_8daaf6152771_a729e2f67de4', // Safari statique (curl-impersonate-safari / tls-client)
714
+ 't13d1516h2_8daaf6152771_4be0df930c2c', // Alternatif Chrome (tls-client Go)
715
+ 't12d1516h2_8daaf6152771_390237aa04be', // Chrome usurpé dégradé en TLS 1.2
716
+ 't13d1516h2_e822d36d892d_93ec3f0b2f5b' // Scraping bot OpenSSL customisé
717
+ ];
718
+ if (in_array($ja4, $spoofedJa4s, true)) {
719
+ $tlsSpoofingScore = max($tlsSpoofingScore, 100.0);
720
+ }
721
+
722
+ $parsedJa4 = $this->parseJa4($ja4);
723
+ if ($parsedJa4) {
724
+ $ua = $context->getHeader('user-agent') ?? '';
725
+ $uaParts = $this->parseUserAgent($ua);
726
+
727
+ // Check 1: Incohérence ALPN / HTTP Version
728
+ $httpVersion = $context->httpVersion ?? '1.1';
729
+ if ($parsedJa4['alpn'] === 'h2' && ($httpVersion === '1.1' || $httpVersion === '1.0')) {
730
+ $hasProxy = $context->getHeader('via') || $context->getHeader('forwarded') || $context->getHeader('x-forwarded-proto') || $context->getHeader('x-forwarded-for');
731
+ if (!$hasProxy) {
732
+ $tlsSpoofingScore = max($tlsSpoofingScore, 40.0);
733
+ }
734
+ }
735
+
736
+ // Check 2: Incohérence OS/Plateforme vs Capabilities TLS
737
+ if ($parsedJa4['version'] === '12' && ($uaParts['os'] === 'iOS' || $uaParts['os'] === 'macOS') && ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Safari'))) {
738
+ $tlsSpoofingScore = max($tlsSpoofingScore, 60.0);
739
+ }
740
+
741
+ // Check 3: Incohérence User-Agent vs Signature JA4
742
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Chrome') && $parsedJa4['alpn'] === '00') {
743
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
744
+ }
745
+ if ($uaParts['browser'] && str_starts_with($uaParts['browser'], 'Firefox') && $parsedJa4['extensionsCount'] > 15) {
746
+ $tlsSpoofingScore = max($tlsSpoofingScore, 50.0);
747
+ }
748
+
749
+ // Check 4: La stagnation (Lack of Entropy / Genericity)
750
+ if (is_string($uaParts['browser'])) {
751
+ $ja4Key = "ja4-browsers:{$ja4}";
752
+ $seenBrowsers = $store->get($ja4Key) ?: [];
753
+ if (!is_array($seenBrowsers)) {
754
+ $seenBrowsers = [];
755
+ }
756
+ $browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
757
+ if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
758
+ $seenBrowsers[] = $browserFamily;
759
+ $store->set($ja4Key, $seenBrowsers, 86400);
760
+ }
761
+ if (count($seenBrowsers) > 1) {
762
+ $tlsSpoofingScore = max($tlsSpoofingScore, 80.0);
763
+ }
764
+ }
765
+ }
766
+ }
767
+
768
+ // Score d'incohérence temporelle (attaque par rejeu)
769
+ $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
770
+
771
+ // Score des incohérences entre couches (client vs serveur)
772
+ $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
773
+
774
+ // Score des patterns de requêtes (scraping, vélocité)
775
+ $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
776
+
777
+ // Score des honeypots
778
+ $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
779
+
780
+ // Score des métriques comportementales client (souris, clavier)
781
+ $behavior = RequestUtils::getBehaviorScore($context);
782
+
783
+ // Score de détection de bot explicite (marqueurs d'automatisation)
784
+ $bot = RequestUtils::getBotScore($context);
785
+
786
+ // Score de variance des clics
787
+ $clickVariance = RequestUtils::getClickVarianceScore($context);
788
+
789
+ // Score basé sur les listes de menaces (Threat Intelligence)
790
+ $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
791
+
792
+ // Score d'incohérence des Client-Hints
793
+ $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
794
+
795
+ // Score de similarité globale de l'empreinte (Clustering Botnet)
796
+ $stableFp = RequestUtils::extractStablePart($currentDeviceHash);
797
+ $stableFpHash = FingerprintBuilder::cyrb53($stableFp);
798
+ $botnetCluster = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
799
+
800
+ // NOUVEAU: Score de réputation du sous-réseau IP
801
+ $subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
802
+
803
+ // Score d'anomalie de pile TCP/IP
804
+ $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
805
+
806
+ // Score d'anomalie de protocole (HTTP/2 et QUIC)
807
+ $protocolAnomaly = RequestUtils::getProtocolAnomalyScore($context);
808
+
809
+ // Score d'anomalie de rendu d'affichage (V-Sync)
810
+ $renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
811
+
812
+ // Assemblage du vecteur de suspicion final
813
+ $suspicionVector = array_merge($suspicionVector, [
814
+ 'inconsistencyScore' => $inconsistencyScore,
815
+ 'historyScore' => $behavioral['historyScore'],
816
+ 'rotationScore' => $behavioral['rotationScore'],
817
+ 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
818
+ 'tlsSpoofingScore' => $tlsSpoofingScore,
819
+ 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
820
+ 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
821
+ 'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
822
+ 'honeypotScore' => $honeypot['honeypotScore'],
823
+ 'behaviorScore' => $behavior['behaviorScore'],
824
+ 'botScore' => $bot['botScore'],
825
+ 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
826
+ 'threatIntelScore' => $threatIntel['threatIntelScore'],
827
+ 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
828
+ 'subnetScore' => $subnetScore['subnetScore'],
829
+ 'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
830
+ 'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
831
+ 'protocolAnomalyScore' => $protocolAnomaly['protocolAnomalyScore'],
832
+ 'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
833
+ ]);
834
+
835
+ // Sauvegarder l'état mis à jour de l'appareil dans le store
836
+ $store->set("device:{$deviceId}", $deviceData);
837
+
838
+ return $suspicionVector;
839
+ }
840
+
841
+ /**
842
+ * Parse a basic User-Agent string.
843
+ */
844
+ private function parseUserAgent(string $ua): array
845
+ {
846
+ $result = ['browser' => null, 'os' => null];
847
+
848
+ if (str_contains($ua, 'Chrome') && !str_contains($ua, 'Edg')) {
849
+ $result['browser'] = 'Chrome';
850
+ if (preg_match('/Chrome\/(\d+)/', $ua, $matches)) {
851
+ $result['browser'] .= '/' . $matches[1];
852
+ }
853
+ } elseif (str_contains($ua, 'Firefox')) {
854
+ $result['browser'] = 'Firefox';
855
+ if (preg_match('/Firefox\/(\d+)/', $ua, $matches)) {
856
+ $result['browser'] .= '/' . $matches[1];
857
+ }
858
+ } elseif (str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome')) {
859
+ $result['browser'] = 'Safari';
860
+ if (preg_match('/Version\/(\d+)/', $ua, $matches)) {
861
+ $result['browser'] .= '/' . $matches[1];
862
+ }
863
+ } elseif (str_contains($ua, 'Edg')) {
864
+ $result['browser'] = 'Edge';
865
+ if (preg_match('/Edg\/(\d+)/', $ua, $matches)) {
866
+ $result['browser'] .= '/' . $matches[1];
867
+ }
868
+ }
869
+
870
+ if (str_contains($ua, 'Windows NT 10.0')) $result['os'] = 'Windows 10';
871
+ elseif (str_contains($ua, 'Windows NT 6.1')) $result['os'] = 'Windows 7';
872
+ elseif (str_contains($ua, 'Mac OS X')) $result['os'] = 'macOS';
873
+ elseif (str_contains($ua, 'Linux') && !str_contains($ua, 'Android')) $result['os'] = 'Linux';
874
+ elseif (str_contains($ua, 'Android')) $result['os'] = 'Android';
875
+ elseif (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) $result['os'] = 'iOS';
876
+
877
+ return $result;
878
+ }
879
+
880
+ /**
881
+ * Parse JA4 string into protocol, version, ALPN etc.
882
+ */
883
+ private function parseJa4(?string $ja4): ?array
884
+ {
885
+ if (empty($ja4)) return null;
886
+ $parts = explode('_', $ja4);
887
+ $ja4a = $parts[0];
888
+ if (strlen($ja4a) < 10) return null;
889
+ return [
890
+ 'protocol' => $ja4a[0],
891
+ 'version' => substr($ja4a, 1, 2),
892
+ 'sni' => $ja4a[3],
893
+ 'ciphersCount' => (int)substr($ja4a, 4, 2),
894
+ 'extensionsCount' => (int)substr($ja4a, 6, 2),
895
+ 'alpn' => substr($ja4a, 8, 2),
896
+ 'ja4b' => $parts[1] ?? null,
897
+ 'ja4c' => $parts[2] ?? null
898
+ ];
899
+ }
900
+
901
+
902
+ private function decodePolymorphicFingerprint(string $fpString, array $mapping): string
903
+ {
904
+ if (empty($mapping['keys'])) {
905
+ return $fpString;
906
+ }
907
+ $reverseKeys = array_flip($mapping['keys']);
908
+ $parts = explode('|', $fpString);
909
+ $mappedParts = [];
910
+ foreach ($parts as $part) {
911
+ $pair = explode(':', $part, 2);
912
+ if (count($pair) === 2) {
913
+ $origKey = $reverseKeys[$pair[0]] ?? $pair[0];
914
+ $mappedParts[] = "{$origKey}:{$pair[1]}";
915
+ } else {
916
+ $mappedParts[] = $part;
917
+ }
918
+ }
919
+ return implode('|', $mappedParts);
920
+ }
921
+ private function translatePolymorphicHeaders(RequestContext $context): void
922
+ {
923
+ $store = StoreManager::getStore();
924
+ $activeMappings = $store->get('active-polymorphic-mappings') ?: [];
925
+ if (!is_array($activeMappings)) {
926
+ return;
927
+ }
928
+
929
+ foreach ($activeMappings as $mapping) {
930
+ $devFpHeader = strtolower($mapping['headers']['x-device-fingerprint'] ?? '');
931
+ $behaviorHeader = strtolower($mapping['headers']['x-behavior-metrics'] ?? '');
932
+
933
+ if (!empty($devFpHeader) && isset($context->headers[$devFpHeader])) {
934
+ $context->headers['x-device-fingerprint'] = $context->headers[$devFpHeader];
935
+ if (!empty($behaviorHeader) && isset($context->headers[$behaviorHeader])) {
936
+ $context->headers['x-behavior-metrics'] = $context->headers[$behaviorHeader];
937
+ }
938
+
939
+ $clientFp = $context->headers['x-device-fingerprint'];
940
+ if ($clientFp && is_string($clientFp)) {
941
+ $context->headers['x-device-fingerprint'] = $this->decodePolymorphicFingerprint($clientFp, $mapping);
942
+ }
943
+ break;
944
+ }
945
+ }
946
+ }
947
+
948
+ /**
949
+ * Évalue si la requête présente des caractéristiques d'attaque flagrantes ou a déclenché un honeypot.
950
+ */
951
+ private function hasCertainAttack(RequestContext $context): bool
952
+ {
953
+ $honeypotConfig = $this->securityConfig['honeypot'] ?? [];
954
+ $honeypot = RequestUtils::getHoneypotScore($context, $honeypotConfig);
955
+ if (($honeypot['honeypotScore'] ?? 0.0) >= 100.0) {
956
+ return true;
957
+ }
958
+ $bot = RequestUtils::getBotScore($context);
959
+ if (($bot['botScore'] ?? 0.0) >= 100.0) {
960
+ return true;
961
+ }
962
+ return false;
963
+ }
964
+
965
+ /**
966
+ * Traite une requête entrante et retourne une décision.
967
+ * @param RequestContext $context Le contexte de la requête.
968
+ * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
969
+ */
970
+ public function processRequest(RequestContext $context): array
971
+ {
972
+ $this->translatePolymorphicHeaders($context);
973
+
974
+ // Initialiser le vecteur de suspicion pour éviter les erreurs de type.
975
+ $suspicionVector = [];
976
+
977
+ $coopOp = $context->query['coop_op'] ?? null;
978
+ if ($coopOp) {
979
+ $result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
980
+ return [
981
+ 'action' => 'challenge',
982
+ 'status' => 200,
983
+ 'body' => $result
984
+ ];
985
+ }
986
+
987
+ $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
988
+
989
+ // Parse GraphQL query if applicable
990
+ if ($context->path === '/graphql' && !empty($context->body)) {
991
+ $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
992
+ if ($gqlInfo) {
993
+ $context->graphqlOperation = $gqlInfo;
994
+ }
995
+ }
996
+
997
+ // 1. Vérifier les listes blanches
998
+ if ($this->checkAllowlists($context)) {
999
+ MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
1000
+ return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
1001
+ }
1002
+
1003
+ // Initialiser le vecteur de suspicion
1004
+ $thresholds = $this->securityConfig['thresholds'];
1005
+
1006
+ // 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
1007
+ $powNonce = $context->query['pow_nonce'] ?? null;
1008
+ $isChallengeSubmission = $powNonce && (
1009
+ isset($context->query['pow_solution']) ||
1010
+ isset($context->query['pow_solution_cpu']) ||
1011
+ isset($context->query['pow_solution_space']) ||
1012
+ (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
1013
+ );
1014
+ if ($isChallengeSubmission) {
1015
+ $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
1016
+ $store = StoreManager::getStore();
1017
+ $challengeContext = $store->get("secret:{$powNonce}");
1018
+ $powType = $context->query['pow_type'] ?? null;
1019
+
1020
+ if ($challengeContext) {
1021
+ $isValid = false;
1022
+ $ticket = null;
1023
+
1024
+ // Vérification de la cohérence du fingerprint
1025
+ $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
1026
+ $originalFingerprint = $challengeContext['fingerprint'] ?? '';
1027
+ $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
1028
+ $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
1029
+
1030
+ if ($similarity < $similarityThreshold) {
1031
+ $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
1032
+ 'similarity' => round($similarity, 4),
1033
+ 'threshold' => $similarityThreshold
1034
+ ], 'warn');
1035
+ $isValid = false;
1036
+ } else {
1037
+ // Le fingerprint est cohérent, on peut valider la solution
1038
+ if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
1039
+ $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
1040
+ if ($cpuSolution) {
1041
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
1042
+ $isValid = $ticket !== null;
1043
+
1044
+ if ($powType === 'cpu_mem') {
1045
+ $memSolution = $context->query['pow_solution_mem'] ?? null;
1046
+ $isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
1047
+ $isValid = $isValid && $isMemValid;
1048
+ }
1049
+ }
1050
+ } elseif ($powType === 'pospace') {
1051
+ $powSolutionSpace = $context->query['pow_solution_space'] ?? null;
1052
+ if ($powSolutionSpace && isset($challengeContext['queries'])) {
1053
+ $isSpaceValid = ChallengeUtils::verifySpacePoW(
1054
+ $powNonce,
1055
+ $powSolutionSpace,
1056
+ $challengeContext['queries'],
1057
+ $powNonce . ":" . $challengeContext['clientSecret'],
1058
+ $challengeContext['clientSecret']
1059
+ );
1060
+ $isValid = $isSpaceValid;
1061
+ if ($isValid) {
1062
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1063
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1064
+ $ticket = ChallengeUtils::generateStatelessTicket([
1065
+ 'expiry' => $expiry,
1066
+ 'originalIp' => $context->clientIp,
1067
+ 'deviceId' => '',
1068
+ 'deviceHash' => ''
1069
+ ]);
1070
+ }
1071
+ }
1072
+ } elseif ($powType === 'useful_work_task') {
1073
+ $problemId = $context->query['pow_problem_id'] ?? null;
1074
+ $workResultJson = $context->query['pow_solution_work_result'] ?? null;
1075
+ if ($problemId && $workResultJson) {
1076
+ $workResult = json_decode($workResultJson, true);
1077
+ $this->log('Verifying useful work solution.', [
1078
+ 'problemId' => $problemId,
1079
+ 'receivedData' => $workResult,
1080
+ 'jsonLastError' => json_last_error_msg()
1081
+ ]);
1082
+ if (json_last_error() === JSON_ERROR_NONE) {
1083
+ // @phpstan-ignore-next-line - L'instance est gérée par le singleton
1084
+ $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1085
+ $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1086
+ $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
1087
+ // FIX: La solution est directement le $workResult, pas une sous-propriété.
1088
+ $problemManager->integrateSolution($problemId, $workResult);
1089
+
1090
+ // Si le problème résolu est l'auto-tuning de sécurité et que l'auto-tuning est activé,
1091
+ // on applique directement la meilleure solution calculée au moteur en direct.
1092
+ if ($problemId === 'security_auto_tuning' && ($this->securityConfig['autotuning']['enabled'] ?? false)) {
1093
+ $paretoFront = $workResult['paretoFront'] ?? null;
1094
+ if (is_array($paretoFront) && !empty($paretoFront)) {
1095
+ $bestSolution = $paretoFront[0];
1096
+ $minDistance = sqrt(pow((float)$bestSolution['objectives'][0], 2) + pow((float)$bestSolution['objectives'][1], 2));
1097
+ for ($i = 1; $i < count($paretoFront); $i++) {
1098
+ $distance = sqrt(pow((float)$paretoFront[$i]['objectives'][0], 2) + pow((float)$paretoFront[$i]['objectives'][1], 2));
1099
+ if ($distance < $minDistance) {
1100
+ $minDistance = $distance;
1101
+ $bestSolution = $paretoFront[$i];
1102
+ }
1103
+ }
1104
+ if (isset($bestSolution['solution'])) {
1105
+ $this->updateConfig($bestSolution['solution']);
1106
+ $this->log('Useful Work auto-tuning applied successfully to live config.');
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ $isValid = true;
1112
+ // FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
1113
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
1114
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
1115
+ $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
1116
+ $ticket = "{$expiry}:{$signature}";
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ if ($isValid) {
1123
+ $store->delete("secret:{$powNonce}");
1124
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
1125
+ MetricsManager::incrementCounter('challenges_solved_total');
1126
+ $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
1127
+
1128
+ $isHttps = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
1129
+ (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
1130
+ ($context->getHeader('x-forwarded-proto') === 'https');
1131
+ $secureOption = $isHttps || $this->isProduction;
1132
+
1133
+ return [
1134
+ 'action' => 'redirect',
1135
+ 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
1136
+ 'score' => 0.0,
1137
+ 'vector' => ['challenge_solved' => 100],
1138
+ 'cookie' => [
1139
+ 'name' => 'pow_clearance',
1140
+ 'value' => $ticket,
1141
+ 'options' => [
1142
+ 'httponly' => true,
1143
+ 'secure' => $secureOption,
1144
+ 'samesite' => 'Strict',
1145
+ 'expires' => time() + ($ticketTtl / 1000),
1146
+ 'path' => '/',
1147
+ 'partitioned' => $secureOption,
1148
+ ]
1149
+ ]
1150
+ ];
1151
+ }
1152
+ }
1153
+ // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
1154
+ MetricsManager::incrementCounter('challenges_failed_total');
1155
+ $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
1156
+ $suspicionVector['honeypotScore'] = 100.0;
1157
+ }
1158
+
1159
+ // 3. Vérifier un ticket existant
1160
+ $hasValidTicket = false;
1161
+ $powCookie = $context->cookies['pow_clearance'] ?? null;
1162
+ $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1163
+ $deviceId = $context->cookies['device_id'] ?? '';
1164
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
1165
+ $allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
1166
+ if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
1167
+ $hasValidTicket = true;
1168
+ MetricsManager::incrementCounter('tickets_valid_total');
1169
+ // On ne retourne pas tout de suite pour permettre le re-challenge
1170
+ // $this->log('Valid clearance ticket found');
1171
+ // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
1172
+ }
1173
+
1174
+ // 4. Calculer le vecteur et le score de suspicion
1175
+ // Résoudre l'identité et vérifier le statut "condamné"
1176
+ $store = StoreManager::getStore();
1177
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
1178
+ $deviceId = $identity['deviceId'];
1179
+ $deviceData = $identity['deviceData'];
1180
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
1181
+ $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
1182
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1183
+ if ($this->dryRun) {
1184
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1185
+ $decision['intendedAction'] = $decision['action'];
1186
+ $decision['action'] = 'next';
1187
+ unset($decision['status'], $decision['body']);
1188
+ }
1189
+ return $decision;
1190
+ }
1191
+
1192
+ $suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
1193
+ $finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
1194
+ $this->log('Suspicion vector and final score calculated', [
1195
+ 'finalScore' => round($finalScore, 2),
1196
+ 'vector' => $suspicionVector
1197
+ ]);
1198
+
1199
+ // Mettre à jour les métriques du sous-réseau après le calcul du score final
1200
+ if ($finalScore >= ($thresholds['medium'] ?? 45)) {
1201
+ RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
1202
+ MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'high_score_subnet_update']);
1203
+ }
1204
+
1205
+ // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
1206
+ $isNewDevice = $identity['newCookie'] !== null;
1207
+ if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
1208
+ $this->log('New device - enforcing minimum challenge score', [
1209
+ 'originalScore' => round($finalScore, 2),
1210
+ 'enforcedScore' => (float)$thresholds['low']
1211
+ ]);
1212
+ $finalScore = (float)$thresholds['low'];
1213
+ }
1214
+
1215
+ // Vérifier les URL pièges (après calcul du score)
1216
+ $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
1217
+ if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
1218
+ if ($this->logger) {
1219
+ $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
1220
+ }
1221
+ $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
1222
+ $deviceData['condemned'] = true; // @phpstan-ignore-line
1223
+ $store->set("device:{$deviceId}", $deviceData);
1224
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
1225
+ if ($this->dryRun) {
1226
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1227
+ $decision['intendedAction'] = $decision['action'];
1228
+ $decision['action'] = 'next';
1229
+ unset($decision['status'], $decision['body']);
1230
+ }
1231
+ return $decision;
1232
+ }
1233
+
1234
+ // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
1235
+ $blockThreshold = $thresholds['block'] ?? 95;
1236
+ $isBlocked = $finalScore >= $blockThreshold;
1237
+ if ($isBlocked) {
1238
+ if ($this->logger) {
1239
+ MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
1240
+ $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1241
+ }
1242
+
1243
+ // Federated Threat Intelligence & ZKP Synchronization
1244
+ $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1245
+ $parts = explode(':', $zkpProof);
1246
+ if (count($parts) === 3) {
1247
+ $zkpY = $parts[0];
1248
+ $zkpT = $parts[1];
1249
+ $zkpS = $parts[2];
1250
+ // 1. Valider cryptographiquement la preuve avant de bannir/diffuser
1251
+ if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
1252
+ // 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
1253
+ $peersKey = "fed-peers:{$zkpY}";
1254
+ $reportedPeers = $store->get($peersKey) ?: [];
1255
+ if (!is_array($reportedPeers)) {
1256
+ $reportedPeers = [];
1257
+ }
1258
+ if (!in_array('local', $reportedPeers, true)) {
1259
+ $reportedPeers[] = 'local';
1260
+ $store->set($peersKey, $reportedPeers, 86400 * 30);
1261
+ }
1262
+
1263
+ $threshold = $this->securityConfig['federationConsensusThreshold'] ?? 3;
1264
+ if (count($reportedPeers) >= $threshold) {
1265
+ $store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
1266
+ }
1267
+ $this->broadcastBannedZkp($zkpY);
1268
+ }
1269
+ }
1270
+
1271
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1272
+ if ($this->dryRun) {
1273
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1274
+ $decision['intendedAction'] = $decision['action'];
1275
+ $decision['action'] = 'next';
1276
+ unset($decision['status'], $decision['body']);
1277
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
1278
+ }
1279
+ $response = $decision;
1280
+ } else {
1281
+ // Logique de re-challenge
1282
+ // Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
1283
+ if ($powNonce && !$isChallengeSubmission) {
1284
+ $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
1285
+ $suspicionVector['honeypotScore'] = 100.0;
1286
+ $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
1287
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1288
+ // Apply dry run logic here as well
1289
+ if ($this->dryRun) {
1290
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_block']);
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
+ $highThreshold = $thresholds['high'] ?? 75;
1300
+ $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket && $finalScore > 0 && !$isBlocked;
1301
+
1302
+ $lowThreshold = $thresholds['low'] ?? 20;
1303
+ if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
1304
+ if ($mustReChallenge) {
1305
+ $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
1306
+ }
1307
+
1308
+ // --- AJOUT: Limiteur de débit (Token Bucket) ---
1309
+ $rateLimitPassed = ChallengeUtils::checkChallengeRateLimit($context->clientIp);
1310
+ if (!$rateLimitPassed) {
1311
+ $this->log('Challenge rate limit exceeded - blocking with 429', ['clientIp' => $context->clientIp]);
1312
+ $decision = [
1313
+ 'action' => 'block',
1314
+ 'status' => 429,
1315
+ 'body' => 'Too Many Requests',
1316
+ 'score' => $finalScore,
1317
+ 'vector' => $suspicionVector
1318
+ ];
1319
+ if ($this->dryRun) {
1320
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1321
+ $decision['intendedAction'] = $decision['action'];
1322
+ $decision['action'] = 'next';
1323
+ unset($decision['status'], $decision['body']);
1324
+ }
1325
+ return $decision;
1326
+ }
1327
+
1328
+ $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
1329
+
1330
+ if ($this->dryRun) {
1331
+ MetricsManager::incrementCounter('requests_total', ['status' => 'dry_run_challenge']);
1332
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
1333
+ $decision['intendedAction'] = $decision['action'];
1334
+ $decision['action'] = 'next';
1335
+ unset($decision['status']);
1336
+ return $decision;
1337
+ }
1338
+
1339
+ MetricsManager::incrementCounter('requests_total', ['status' => 'challenged']);
1340
+ $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
1341
+
1342
+ $nonce = bin2hex(random_bytes(16));
1343
+ $clientSecret = bin2hex(random_bytes(16));
1344
+ $highThreshold = $thresholds['high'] ?? 75;
1345
+
1346
+ $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
1347
+ $suspicionFactor = max(0, min(1.5, $suspicionFactor));
1348
+
1349
+ // --- NOUVELLE LOGIQUE uPoW ---
1350
+ $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
1351
+ ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
1352
+ );
1353
+
1354
+ // Déterminer si c'est une requête API avant de choisir le type de challenge
1355
+ $isApiRequest = false;
1356
+ if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
1357
+ $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
1358
+ }
1359
+
1360
+ if ($shouldUseUsefulWork) {
1361
+ $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1362
+ $defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
1363
+ $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1364
+ $problemManager = ProblemManager::getInstance($configPath, $store);
1365
+ $work = $problemManager->dispatchWork($finalScore);
1366
+
1367
+ if ($work !== null) {
1368
+ $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
1369
+ $challengePayload = [
1370
+ 'challenge' => [
1371
+ 'type' => 'useful_work_task',
1372
+ 'nonce' => $nonce,
1373
+ 'clientSecret' => $clientSecret,
1374
+ 'usefulWorkTask' => [
1375
+ 'problemId' => $work['problemId'],
1376
+ 'task' => $work['task']
1377
+ ]
1378
+ ]
1379
+ ];
1380
+ if ($isApiRequest) {
1381
+ $decision['body'] = $challengePayload;
1382
+ return $decision;
1383
+ } else {
1384
+ $html = '<html><body><script>';
1385
+ $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}));';
1386
+ $html .= '</script></body></html>';
1387
+ $decision['body'] = $html;
1388
+ return $decision;
1389
+ }
1390
+ } else {
1391
+ // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
1392
+ // We log it and fall through to the standard PoW challenge.
1393
+ $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
1394
+ $shouldUseUsefulWork = false; // Explicitly disable for this request
1395
+ }
1396
+ }
1397
+
1398
+ // --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
1399
+
1400
+ if ($this->securityConfig['enableProofOfSpace'] ?? false) {
1401
+ $spaceChallenge = ChallengeUtils::generateSpaceChallenge($context->clientIp, $nonce, $suspicionFactor, $context->path, $this->securityConfig);
1402
+ $store->set("secret:{$nonce}", [
1403
+ 'clientSecret' => $clientSecret,
1404
+ 'suspicionScore' => $finalScore,
1405
+ 'queries' => $spaceChallenge['queries'],
1406
+ 'sizeMb' => $spaceChallenge['sizeMb'],
1407
+ 'fingerprint' => RequestUtils::getCompositeDeviceHash($context),
1408
+ 'originalPath' => $context->path,
1409
+ ], $this->securityConfig['challengeTtl'] ?? 300);
1410
+
1411
+ if ($deviceData) {
1412
+ $deviceData['lastChallengeNonce'] = $nonce;
1413
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1414
+ }
1415
+
1416
+ if ($isApiRequest) {
1417
+ $decision['body'] = [
1418
+ 'challenge' => [
1419
+ 'type' => 'pospace',
1420
+ 'nonce' => $nonce,
1421
+ 'clientSecret' => $clientSecret,
1422
+ 'queries' => $spaceChallenge['queries'],
1423
+ 'sizeMb' => $spaceChallenge['sizeMb'],
1424
+ ]
1425
+ ];
1426
+ } else {
1427
+ $page = ChallengeUtils::generateSpaceChallengePage($spaceChallenge, $clientSecret, $this->securityConfig);
1428
+ $decision['body'] = $page;
1429
+ }
1430
+ return $decision;
1431
+ }
1432
+
1433
+ $cpuChallengeDetails = [
1434
+ 'type' => 'cpu_target',
1435
+ 'nonce' => $nonce,
1436
+ 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1437
+ 'path' => $context->path,
1438
+ ];
1439
+
1440
+ // Alignement linéaire parfait du ratio d'effort CPU/Mémoire
1441
+ $memActivationFactor = $suspicionFactor;
1442
+ $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
1443
+
1444
+ $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
1445
+ $tlsSessionId = $context->tlsSessionId ?? '';
1446
+ $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint, $context->clientIp, $tlsSessionId);
1447
+
1448
+ $challengeContext = [
1449
+ 'clientSecret' => $clientSecret,
1450
+ 'cpuTarget' => $cpuChallengeDetails['target'],
1451
+ 'suspicionScore' => $finalScore,
1452
+ 'fingerprint' => $originalFingerprint,
1453
+ 'memDifficulty' => $memDifficulty,
1454
+ 'baseBlock' => $baseBlock,
1455
+ 'originalPath' => $context->path,
1456
+ ];
1457
+
1458
+ $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
1459
+
1460
+ // Associer le nonce au device pour la vérification des URL pièges
1461
+ if ($deviceData) {
1462
+ $deviceData['lastChallengeNonce'] = $nonce;
1463
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
1464
+ }
1465
+
1466
+ $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
1467
+ $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
1468
+
1469
+ if ($this->logger) {
1470
+ $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1471
+ }
1472
+
1473
+ // Pour les API, retourner un challenge JSON
1474
+ if ($isApiRequest) {
1475
+ $challengePayload = [
1476
+ 'challenge' => [
1477
+ 'type' => 'cpu_mem',
1478
+ 'nonce' => $nonce,
1479
+ 'clientSecret' => $clientSecret,
1480
+ 'cpuTarget' => $cpuChallengeDetails['target'],
1481
+ 'memDifficulty' => $memDifficulty,
1482
+ 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
1483
+ ]
1484
+ ];
1485
+ $decision['body'] = $challengePayload;
1486
+ } else {
1487
+ // Pour les navigateurs, retourner une page HTML
1488
+ $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
1489
+ $cpuChallengeDetails, $memDifficulty, $clientSecret,
1490
+ $this->securityConfig, $trapUrls, $originalFingerprint
1491
+ );
1492
+ $decision['body'] = $pageBody;
1493
+ }
1494
+ $response = $decision;
1495
+ } elseif ($hasValidTicket) {
1496
+ // Si on arrive ici avec un ticket valide et un score bas, on autorise
1497
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1498
+ $this->log('Valid clearance ticket found and score is low - allowing request');
1499
+ $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
1500
+ } else {
1501
+ // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
1502
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
1503
+ $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
1504
+ if ($this->logger) {
1505
+ MetricsManager::observeValue('suspicion_score', $finalScore, ['action' => 'passed']);
1506
+ $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1507
+ }
1508
+ $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
1509
+ }
1510
+ }
1511
+
1512
+ // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
1513
+ if (isset($context->newCookieForResponse)) {
1514
+ $response['newCookieForResponse'] = $context->newCookieForResponse;
1515
+ }
1516
+
1517
+ return $response;
1518
+ }
1519
+
1520
+ /**
1521
+ * Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
1522
+ */
1523
+ private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
1524
+ {
1525
+ if (empty($operationType) || empty($operationName)) {
1526
+ return false;
1527
+ }
1528
+
1529
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1530
+ $graphqlRule = null;
1531
+ foreach ($whitelistRules as $rule) {
1532
+ if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
1533
+ $graphqlRule = $rule;
1534
+ break;
1535
+ }
1536
+ }
1537
+
1538
+ if (empty($graphqlRule['entries'])) {
1539
+ return false;
1540
+ }
1541
+
1542
+ foreach ($graphqlRule['entries'] as $entry) {
1543
+ [$entryType, $entryName] = explode(':', $entry, 2);
1544
+ if ($entryType !== $operationType) continue;
1545
+
1546
+ if ($entryName === $operationName || $entryName === '*') return true;
1547
+
1548
+ if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
1549
+ }
1550
+
1551
+ return false;
1552
+ }
1553
+
1554
+
1555
+ private function broadcastBannedZkp(string $zkpY): void
1556
+ {
1557
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
1558
+ if (empty($peers)) return;
1559
+
1560
+ $timestamp = (int)(microtime(true) * 1000);
1561
+ $msg = "{$timestamp}:{$zkpY}";
1562
+
1563
+ $signature = '';
1564
+ $isAsymmetric = false;
1565
+
1566
+ $privateKey = Env::get('ED25519_PRIVATE_KEY');
1567
+ if ($privateKey) {
1568
+ try {
1569
+ $cleanKey = str_replace('\n', "\n", $privateKey);
1570
+ $pkeyObj = openssl_pkey_get_private($cleanKey);
1571
+ if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
1572
+ $signature = bin2hex($sigBytes);
1573
+ $isAsymmetric = true;
1574
+ }
1575
+ } catch (\Throwable $e) {
1576
+ error_log('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
1577
+ }
1578
+ }
1579
+
1580
+ if (!$isAsymmetric) {
1581
+ $secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
1582
+ $signature = hash_hmac('sha256', $msg, $secret);
1583
+ }
1584
+
1585
+ foreach ($peers as $peerUrl) {
1586
+ $this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
1587
+ 'zkpY' => $zkpY,
1588
+ 'signature' => $isAsymmetric ? '' : $signature,
1589
+ 'signature_ed25519' => $isAsymmetric ? $signature : '',
1590
+ 'timestamp' => $timestamp
1591
+ ]);
1592
+ }
1593
+ }
1594
+
1595
+ private function asyncPost(string $url, array $params): void
1596
+ {
1597
+ $parts = parse_url($url);
1598
+ if ($parts === false) return;
1599
+
1600
+ $host = $parts['host'];
1601
+ $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
1602
+ $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
1603
+ $scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
1604
+
1605
+ $postData = json_encode($params);
1606
+
1607
+ $fp = @stream_socket_client(
1608
+ "{$scheme}{$host}:{$port}",
1609
+ $errno,
1610
+ $errstr,
1611
+ 0.5,
1612
+ STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
1613
+ );
1614
+
1615
+ if ($fp) {
1616
+ stream_set_blocking($fp, false);
1617
+ $out = "POST {$path} HTTP/1.1\r\n";
1618
+ $out .= "Host: {$host}\r\n";
1619
+ $out .= "Content-Type: application/json\r\n";
1620
+ $out .= "Content-Length: " . strlen($postData) . "\r\n";
1621
+ $out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
1622
+ $out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
1623
+ $out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
1624
+ $out .= "Connection: Close\r\n\r\n";
1625
+ $out .= $postData;
1626
+
1627
+ @fwrite($fp, $out);
1628
+ @fclose($fp);
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
1634
+ * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
1635
+ */
1636
+ private function verifyWhitelistedBot(RequestContext $context): bool
1637
+ {
1638
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
1639
+ $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
1640
+ if (empty($botRules)) {
1641
+ return false;
1642
+ }
1643
+
1644
+ $userAgent = $context->getHeader('user-agent') ?? '';
1645
+ $matchedRule = null;
1646
+ foreach ($botRules as $rule) {
1647
+ if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
1648
+ $matchedRule = $rule;
1649
+ break;
1650
+ }
1651
+ }
1652
+ if ($matchedRule === null) {
1653
+ return false;
1654
+ }
1655
+
1656
+ $store = StoreManager::getStore();
1657
+ $cacheKey = "ip-whitelist:{$context->clientIp}";
1658
+ $cachedStatus = $store->get($cacheKey);
1659
+
1660
+ if ($cachedStatus === 'verified') return true;
1661
+ if ($cachedStatus === 'failed') return false;
1662
+
1663
+ if (!self::canAttemptDns()) {
1664
+ return false;
1665
+ }
1666
+
1667
+ try {
1668
+ // 1. Reverse DNS lookup with strict 500ms timeout using our custom UDP DNS Client
1669
+ $revName = self::getReverseDnsName($context->client_ip);
1670
+ if (!$revName) {
1671
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1672
+ return false;
1673
+ }
1674
+ $hostnames = self::dnsResolveUdp($revName, 'PTR', 0.5);
1675
+ if ($hostnames === null || empty($hostnames)) {
1676
+ self::recordDnsFailure();
1677
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1678
+ return false;
1679
+ }
1680
+
1681
+ $validHostname = null;
1682
+ foreach ($hostnames as $hostname) {
1683
+ if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
1684
+ $validHostname = $hostname;
1685
+ break;
1686
+ }
1687
+ }
1688
+
1689
+ if ($validHostname === null) {
1690
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1691
+ return false;
1692
+ }
1693
+
1694
+ // 2. Forward DNS lookup with strict 500ms timeout
1695
+ $ips = [];
1696
+ $resolvedA = self::dnsResolveUdp($validHostname, 'A', 0.5);
1697
+ if ($resolvedA === null) {
1698
+ self::recordDnsFailure();
1699
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1700
+ return false;
1701
+ }
1702
+ $ips = array_merge($ips, $resolvedA);
1703
+
1704
+ $resolvedAaaa = self::dnsResolveUdp($validHostname, 'AAAA', 0.5);
1705
+ if ($resolvedAaaa === null) {
1706
+ self::recordDnsFailure();
1707
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1708
+ return false;
1709
+ }
1710
+ $ips = array_merge($ips, $resolvedAaaa);
1711
+
1712
+ if (in_array($context->client_ip, $ips, true)) {
1713
+ self::recordDnsSuccess();
1714
+ $store->set($cacheKey, 'verified', 86400);
1715
+ return true;
1716
+ }
1717
+ } catch (\Exception $e) {
1718
+ self::recordDnsFailure();
1719
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1720
+ return false;
1721
+ }
1722
+
1723
+ $store->set($cacheKey, 'failed', 300); // 5 min negative caching
1724
+ return false;
1725
+ }
1726
+
1727
+ /**
1728
+ * @internal For testing purposes only.
1729
+ */
1730
+ public function getProblems(): array
1731
+ {
1732
+ $problemManager = ProblemManager::getInstance();
1733
+ return $problemManager->getProblems();
1734
+ }
1735
+ private static function decodeCBOR(string $str, int &$offset = 0)
1736
+ {
1737
+ if ($offset >= strlen($str)) throw new \Exception("End of CBOR");
1738
+ $initial = ord($str[$offset++]);
1739
+ $major = $initial >> 5;
1740
+ $val = $initial & 0x1f;
1741
+
1742
+ $readInt = function (int $val, string $str, int &$offset) {
1743
+ if ($val < 24) return $val;
1744
+ if ($val === 24) return ord($str[$offset++]);
1745
+ if ($val === 25) {
1746
+ $b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
1747
+ return ($b1 << 8) | $b2;
1748
+ }
1749
+ if ($val === 26) {
1750
+ $b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
1751
+ $b3 = ord($str[$offset++]); $b4 = ord($str[$offset++]);
1752
+ return ($b1 << 24) | ($b2 << 16) | ($b3 << 8) | $b4;
1753
+ }
1754
+ throw new \Exception("Unsupported integer size: " . $val);
1755
+ };
1756
+
1757
+ if ($major === 0) {
1758
+ return $readInt($val, $str, $offset);
1759
+ } elseif ($major === 1) {
1760
+ return -1 - $readInt($val, $str, $offset);
1761
+ } elseif ($major === 2 || $major === 3) {
1762
+ $len = $readInt($val, $str, $offset);
1763
+ $bytes = substr($str, $offset, $len);
1764
+ $offset += $len;
1765
+ return $bytes;
1766
+ } elseif ($major === 4) {
1767
+ $len = $readInt($val, $str, $offset);
1768
+ $arr = [];
1769
+ for ($i = 0; $i < $len; $i++) {
1770
+ $arr[] = self::decodeCBOR($str, $offset);
1771
+ }
1772
+ return $arr;
1773
+ } elseif ($major === 5) {
1774
+ $len = $readInt($val, $str, $offset);
1775
+ $map = [];
1776
+ for ($i = 0; $i < $len; $i++) {
1777
+ $k = self::decodeCBOR($str, $offset);
1778
+ $v = self::decodeCBOR($str, $offset);
1779
+ $map[$k] = $v;
1780
+ }
1781
+ return $map;
1782
+ }
1783
+ return null;
1784
+ }
1785
+
1786
+ private static function getTrustedHardwareRoots(): array
1787
+ {
1788
+ return [
1789
+ "-----BEGIN CERTIFICATE-----\n" .
1790
+ "MIIDHzCCAfegAwIBAgIJANCvWjvF+2O6MA0GCSqGSIb3DQEBCwUAMC0xKzApBgNV\n" .
1791
+ "BAMTIll1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMB4XDTE0MDgwNDAwMDAwMFox\n" .
1792
+ "TSUxSDBGBgNVBAMMT1l1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMSowKAYDVQQK\n" .
1793
+ "EyFZdWJpY28gQUIxDzANBgNVBAcTBVN0b2NraG9sbTELMAkGA1UEBhMCU0UwggEi\n" .
1794
+ "MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6XW0d87g+N6kGgSgC/H9UfA2p\n" .
1795
+ "-----END CERTIFICATE-----",
1796
+ "-----BEGIN CERTIFICATE-----\n" .
1797
+ "MIIB1DCCAXWgAwIBAgIEUI70WjAKBggqhkjOPQQDAjArMSkwJwYDVQQDEyBGSURP\n" .
1798
+ "IEFsbGlhbmNlIFJvb3QgQ0EgKFRlc3QpMB4XDTE0MDgxODA4MzA0NVoXDTM5MDgx\n" .
1799
+ "ODA4MzA0NVowKzEpMCcGA1UEAxMgRklETyBBbGxpYW5jZSBSb290IENBIChUZXN0\n" .
1800
+ "KTB2MBAGByqGSM49AgEGBSuBBAAiA2IABFv81Jm9M7AehfOIdpCH567gP0yqS40m\n" +
1801
+ "aN0j1a8n152G7n/nUf7J0j9F4pL9J2w1X8hN1N8f9Y3G9w8L29/m7/zX3O3n2e7/\n" .
1802
+ "g==\n" .
1803
+ "-----END CERTIFICATE-----"
1804
+ ];
1805
+ }
1806
+
1807
+ private function verifyWebAuthnHardwareAnchor(array $anchor, array &$deviceData): bool
1808
+ {
1809
+ if (empty($anchor['type'])) return false;
1810
+
1811
+ try {
1812
+ $clientDataHash = hash('sha256', base64_decode($anchor['clientDataJSON']), true);
1813
+
1814
+ if ($anchor['type'] === 'registration') {
1815
+ if (empty($anchor['publicKey']) || empty($anchor['credentialId']) || empty($anchor['attestationObject'])) {
1816
+ return false;
1817
+ }
1818
+
1819
+ $attestationBytes = base64_decode($anchor['attestationObject']);
1820
+ $offset = 0;
1821
+ $decoded = self::decodeCBOR($attestationBytes, $offset);
1822
+
1823
+ if (!$decoded || empty($decoded['fmt']) || empty($decoded['attStmt'])) {
1824
+ return false;
1825
+ }
1826
+
1827
+ $fmt = $decoded['fmt'];
1828
+ $attStmt = $decoded['attStmt'];
1829
+
1830
+ if ($fmt !== 'none') {
1831
+ if (empty($attStmt['x5c']) || !is_array($attStmt['x5c'])) {
1832
+ return false;
1833
+ }
1834
+
1835
+ $pems = [];
1836
+ foreach ($attStmt['x5c'] as $der) {
1837
+ $pems[] = "-----BEGIN CERTIFICATE-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END CERTIFICATE-----";
1838
+ }
1839
+
1840
+ for ($i = 0; $i < count($pems) - 1; $i++) {
1841
+ if (openssl_x509_verify($pems[$i], $pems[$i + 1]) !== 1) {
1842
+ return false;
1843
+ }
1844
+ }
1845
+
1846
+ $rootPem = $pems[count($pems) - 1];
1847
+ $trusted = false;
1848
+ $trustedRoots = self::getTrustedHardwareRoots();
1849
+ foreach ($trustedRoots as $trustedRootPem) {
1850
+ if (openssl_x509_verify($rootPem, $trustedRootPem) === 1 || md5($rootPem) === md5($trustedRootPem)) {
1851
+ $trusted = true;
1852
+ break;
1853
+ }
1854
+ }
1855
+ if (!$trusted) {
1856
+ return false;
1857
+ }
1858
+ }
1859
+
1860
+ $deviceData['webauthnPublicKey'] = $anchor['publicKey'];
1861
+ $deviceData['webauthnCredentialId'] = $anchor['credentialId'];
1862
+ return true;
1863
+ } elseif ($anchor['type'] === 'assertion') {
1864
+ $storedPublicKeyPem = $deviceData['webauthnPublicKey'] ?? null;
1865
+ if (!$storedPublicKeyPem || ($deviceData['webauthnCredentialId'] ?? '') !== $anchor['credentialId']) {
1866
+ return false;
1867
+ }
1868
+
1869
+ $verifyBuffer = base64_decode($anchor['authenticatorData']) . $clientDataHash;
1870
+ $publicKey = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----\n" . chunk_split($storedPublicKeyPem, 64, "\n") . "-----END PUBLIC KEY-----");
1871
+ if (!$publicKey) return false;
1872
+
1873
+ return openssl_verify($verifyBuffer, base64_decode($anchor['signature']), $publicKey, OPENSSL_ALGO_SHA256) === 1;
1874
+ }
1875
+ } catch (\Throwable $e) {
1876
+ error_log('[WebAuthn-Server] PHP verification failed: ' . $e->getMessage());
1877
+ }
1878
+ return false;
1879
+ }
1880
+ }