@anonympins/fingerprint 0.6.7 → 0.7.0

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