@anonympins/fingerprint 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +1080 -834
  3. package/composer.json +39 -0
  4. package/index.js +5 -0
  5. package/package.json +31 -23
  6. package/phpunit.xml +21 -0
  7. package/public/fp.js +2 -0
  8. package/src/js/build-client.js +69 -0
  9. package/{fingerprint.builder.js → src/js/fingerprint.builder.js} +168 -175
  10. package/{fingerprint.client.js → src/js/fingerprint.client.js} +634 -538
  11. package/src/js/fingerprint.client.obfuscated.js +1 -0
  12. package/{fingerprint.js → src/js/fingerprint.js} +3733 -3294
  13. package/{library.js → src/js/library.js} +1729 -1729
  14. package/{problem-manager.js → src/js/problem-manager.js} +539 -522
  15. package/src/php/AutoTuner.php +155 -0
  16. package/src/php/Challenge/ChallengeUtils.php +306 -0
  17. package/src/php/Config/SecurityProfiles.php +267 -0
  18. package/src/php/DirectFingerprint.php +81 -0
  19. package/src/php/FingerprintBuilder.php +186 -0
  20. package/src/php/FingerprintClient.php +132 -0
  21. package/src/php/FingerprintEngine.php +863 -0
  22. package/src/php/Optimization/FunctionRegistry.php +63 -0
  23. package/src/php/Optimization/Optimization.php +256 -0
  24. package/src/php/Optimization/OptimizationOperators.php +305 -0
  25. package/src/php/Optimization/ProblemInitializers.php +53 -0
  26. package/src/php/ProblemManager.php +255 -0
  27. package/src/php/RequestContext.php +91 -0
  28. package/src/php/Store/IStore.php +42 -0
  29. package/src/php/Store/InMemoryStore.php +67 -0
  30. package/src/php/Store/StoreManager.php +36 -0
  31. package/src/php/Tests/ChallengeUtilsTest.php +82 -0
  32. package/src/php/Tests/FingerprintBuilderTest.php +58 -0
  33. package/src/php/Tests/FingerprintEngineTest.php +300 -0
  34. package/src/php/Tests/IpReputationTest.php +157 -0
  35. package/src/php/Tests/PowTest.php +40 -0
  36. package/src/php/Tests/ProblemManagerTest.php +295 -0
  37. package/src/php/Tests/RequestUtilsTest.php +81 -0
  38. package/src/php/Tests/problems.config.json +9 -0
  39. package/src/php/Utils/BigInt.php +145 -0
  40. package/src/php/Utils/BlockList.php +100 -0
  41. package/src/php/Utils/Logger.php +30 -0
  42. package/src/php/Utils/MaliciousPatterns.php +59 -0
  43. package/src/php/Utils/RequestUtils.php +962 -0
  44. package/fingerprint.client.obfuscated.js +0 -1
  45. /package/{mongodb-store.js → src/js/mongodb-store.js} +0 -0
  46. /package/{optimization.worker.js → src/js/optimization.worker.js} +0 -0
  47. /package/{pow.solver.inline.js → src/js/pow.solver.inline.js} +0 -0
  48. /package/{pow.solver.js → src/js/pow.solver.js} +0 -0
  49. /package/{pow.worker.js → src/js/pow.worker.js} +0 -0
  50. /package/{redis-store.js → src/js/redis-store.js} +0 -0
  51. /package/{sql-store.js → src/js/sql-store.js} +0 -0
@@ -0,0 +1,863 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
8
+ use Anonympins\Fingerprint\Store\StoreManager; // Correction de l'import
9
+ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
10
+ use Anonympins\Fingerprint\Utils\BlockList;
11
+ use Anonympins\Fingerprint\Utils\Logger;
12
+ use Anonympins\Fingerprint\Utils\RequestUtils;
13
+
14
+ /**
15
+ * Le moteur principal de la bibliothèque de fingerprinting.
16
+ * Orchestre l'identification, le calcul de suspicion et la gestion des challenges.
17
+ */
18
+ class FingerprintEngine
19
+ {
20
+ private array $securityConfig;
21
+ private bool $isProduction;
22
+ private BlockList $allowlist;
23
+ private bool $verbose;
24
+ private ?Logger $logger;
25
+ private bool $dryRun;
26
+
27
+ public function __construct(array $securityConfig)
28
+ {
29
+ $this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
30
+ $this->securityConfig = $securityConfig;
31
+ $this->verbose = $securityConfig['verbose'] ?? false;
32
+ $this->allowlist = $this->buildAllowlist();
33
+ $this->validateConfig($securityConfig);
34
+ $this->logger = isset($securityConfig['logger']) && is_callable($securityConfig['logger']) ? new Logger($securityConfig['logger']) : null;
35
+ $this->dryRun = $securityConfig['dryRun'] ?? false;
36
+ }
37
+
38
+ private function validateConfig(array $config): void
39
+ {
40
+ if (empty($config)) {
41
+ $this->log('Warning: No securityConfig provided. Using default behaviors.', [], 'warn');
42
+ return;
43
+ }
44
+
45
+ $knownKeys = [
46
+ 'weights', 'thresholds', 'cpu', 'ticketMaxAge', 'challengeTtl',
47
+ 'deviceIdCookieMaxAge', 'challengePagePath', 'verbose', 'patterns',
48
+ 'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
49
+ 'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
50
+ 'similarityThreshold', 'summary', 'description'
51
+ ];
52
+
53
+ if (empty($config['weights'])) {
54
+ $this->log('Warning: `securityConfig.weights` is not defined. Suspicion scores will be 0.', [], 'warn');
55
+ }
56
+ if (empty($config['thresholds'])) {
57
+ $this->log('Warning: `securityConfig.thresholds` is not defined. Challenges may not be issued correctly.', [], 'warn');
58
+ }
59
+
60
+ foreach (array_keys($config) as $key) {
61
+ if (!in_array($key, $knownKeys, true)) {
62
+ $this->log("Warning: Unknown key '{$key}' found in securityConfig. This might be a typo.", [], 'warn');
63
+ }
64
+ }
65
+ }
66
+
67
+ private function log(string $message, array $data = [], string $level = 'info'): void
68
+ {
69
+ if ($this->verbose) {
70
+ // Utilise le logger s'il est configuré, sinon error_log
71
+ if ($this->logger) {
72
+ $this->logger->log($level, "[FingerprintEngine] " . $message, $data);
73
+ } else {
74
+ $logMessage = "[FingerprintEngine] {$message}";
75
+ if (!empty($data)) $logMessage .= ' ' . json_encode($data);
76
+ error_log($logMessage);
77
+ }
78
+ }
79
+ }
80
+
81
+ public function calculateFinalScore(array $suspicionVector): float
82
+ {
83
+ $weights = $this->securityConfig['weights'] ?? [];
84
+ if (empty($weights)) {
85
+ return 0.0;
86
+ }
87
+
88
+ $score = 0.0;
89
+ foreach ($weights as $key => $weight) {
90
+ $score += ($suspicionVector[$key] ?? 0) * $weight;
91
+ }
92
+
93
+ return min(100.0, $score);
94
+ }
95
+
96
+ private function buildAllowlist(): BlockList
97
+ {
98
+ $blockList = new BlockList();
99
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
100
+
101
+ $allowlistRule = null;
102
+ foreach ($whitelistRules as $rule) {
103
+ if (($rule['type'] ?? '') === 'allowlist') {
104
+ $allowlistRule = $rule;
105
+ break;
106
+ }
107
+ }
108
+
109
+ if (empty($allowlistRule['entries'])) {
110
+ return $blockList;
111
+ }
112
+
113
+ foreach ($allowlistRule['entries'] as $entry) {
114
+ $blockList->add($entry);
115
+ }
116
+ return $blockList;
117
+ }
118
+
119
+ private function isIpInAllowlist(string $clientIp): bool
120
+ {
121
+ return $this->allowlist->check($clientIp);
122
+ }
123
+
124
+ private function isPathInAllowlist(string $requestPath): bool
125
+ {
126
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
127
+ $pathAllowlistRule = null;
128
+ foreach ($whitelistRules as $rule) {
129
+ if (($rule['type'] ?? '') === 'path_allowlist') {
130
+ $pathAllowlistRule = $rule;
131
+ break;
132
+ }
133
+ }
134
+
135
+ if (empty($pathAllowlistRule['entries'])) {
136
+ return false;
137
+ }
138
+
139
+ foreach ($pathAllowlistRule['entries'] as $entry) {
140
+ if (str_ends_with($entry, '*')) {
141
+ $base = substr($entry, 0, -1);
142
+ if (str_starts_with($requestPath, $base)) {
143
+ return true;
144
+ }
145
+ } elseif ($requestPath === $entry) {
146
+ return true;
147
+ }
148
+ }
149
+
150
+ return false;
151
+ }
152
+
153
+ private function isHostPathInAllowlist(?string $requestHost, string $requestPath): bool
154
+ {
155
+ if (empty($requestHost)) {
156
+ return false;
157
+ }
158
+
159
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
160
+ $hostPathRule = null;
161
+ foreach ($whitelistRules as $rule) {
162
+ if (($rule['type'] ?? '') === 'host_path_allowlist') {
163
+ $hostPathRule = $rule;
164
+ break;
165
+ }
166
+ }
167
+
168
+ if (empty($hostPathRule['entries'])) {
169
+ return false;
170
+ }
171
+
172
+ foreach ($hostPathRule['entries'] as $entry) {
173
+ if (RequestUtils::hostPathMatches($requestHost, $requestPath, $entry)) {
174
+ return true;
175
+ }
176
+ }
177
+ return false;
178
+ }
179
+
180
+ /**
181
+ * Vérifie si une requête doit être exemptée en raison d'une règle de liste blanche.
182
+ */
183
+ private function checkAllowlists(RequestContext $context): bool
184
+ {
185
+ if ($this->isIpInAllowlist($context->clientIp)) {
186
+ $this->log('IP in allowlist - allowing request', ['clientIp' => $context->clientIp]);
187
+ return true;
188
+ }
189
+ if ($this->isPathInAllowlist($context->path)) {
190
+ $this->log('Path in allowlist - allowing request', ['path' => $context->path]);
191
+ return true;
192
+ }
193
+ if ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
194
+ $this->log('Host and path in allowlist - allowing request', ['host' => $context->getHeader('host'), 'path' => $context->path]);
195
+ return true;
196
+ }
197
+ // NOUVEAU: Vérifier la liste blanche GraphQL
198
+ if ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
199
+ $this->log('GraphQL operation in allowlist - allowing request', ['operation' => "{$context->graphqlOperation['type']}:{$context->graphqlOperation['name']}"]);
200
+ return true;
201
+ }
202
+ if ($this->verifyWhitelistedBot($context)) {
203
+ $this->log('Whitelisted bot verified - allowing request', ['clientIp' => $context->clientIp]);
204
+ return true;
205
+ }
206
+
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * @return array{deviceId: string, deviceData: ?array, newCookie: ?array}
212
+ */
213
+ private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
214
+ {
215
+ $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
216
+ $store = StoreManager::getStore();
217
+ $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
218
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
219
+ $pendingCookieTtl = 120; // 2 minutes pour détecter la suppression
220
+
221
+ $deviceId = $existingDeviceId;
222
+ $deviceData = null;
223
+ $newCookie = null;
224
+
225
+ if ($deviceId) {
226
+ $deviceData = $store->get("device:{$deviceId}");
227
+ }
228
+
229
+ if ($deviceData === null) {
230
+ // Nouvel utilisateur ou cookie perdu/invalide
231
+ $pendingDeviceId = $store->get("pending_cookie:{$context->clientIp}");
232
+ if ($pendingDeviceId && !$existingDeviceId) {
233
+ // La pénalité est maintenant ajoutée directement au vecteur de suspicion.
234
+ $this->log('Cookie dropping detected', ['clientIp' => $context->clientIp, 'pendingDeviceId' => $pendingDeviceId]);
235
+ $suspicionVector['cookieDroppingScore'] = 100.0;
236
+ }
237
+
238
+ $deviceId = bin2hex(random_bytes(16)); // UUID-like
239
+
240
+ // Préparer le cookie à envoyer
241
+ $newCookie = [
242
+ 'name' => 'device_id',
243
+ 'value' => $deviceId,
244
+ 'options' => [
245
+ 'httponly' => true,
246
+ 'secure' => $this->isProduction,
247
+ 'samesite' => 'Strict',
248
+ 'path' => '/',
249
+ ]
250
+ ];
251
+ if (isset($this->securityConfig['deviceIdCookieMaxAge'])) {
252
+ $newCookie['options']['expires'] = time() + ($this->securityConfig['deviceIdCookieMaxAge'] / 1000);
253
+ }
254
+
255
+ $store->set("pending_cookie:{$context->clientIp}", $deviceId, $pendingCookieTtl);
256
+
257
+ $deviceData = [
258
+ 'initialDeviceHash' => $currentDeviceHash,
259
+ 'ips' => [$context->clientIp],
260
+ 'requestHistory' => [],
261
+ 'lastUpdate' => time() * 1000,
262
+ 'lastFpHash' => $currentDeviceHash,
263
+ 'lastChangeTimestamp' => 0,
264
+ 'rapidChangeCount' => 0,
265
+ 'highScoreCount' => 0,
266
+ 'lastHighScoreTimestamp' => 0,
267
+ ];
268
+ } else {
269
+ // S'assurer que 'ips' est un tableau pour les opérations suivantes.
270
+ if (!isset($deviceData['ips']) || !is_array($deviceData['ips'])) { // @phpstan-ignore-line
271
+ $deviceData['ips'] = [];
272
+ }
273
+ }
274
+
275
+ return [
276
+ 'deviceId' => $deviceId,
277
+ 'deviceData' => $deviceData,
278
+ 'newCookie' => $newCookie,
279
+ ];
280
+ }
281
+
282
+ /**
283
+ * @return array<string, float>
284
+ */
285
+ public function getSuspicionVector(RequestContext $context, array &$suspicionVector): array
286
+ {
287
+ $store = StoreManager::getStore();
288
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
289
+ $deviceData = $identity['deviceData'];
290
+ $deviceId = $identity['deviceId'];
291
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
292
+ $suspicionVector['honeypotScore'] = 100;
293
+ return $suspicionVector;
294
+ }
295
+
296
+ // Si un nouveau cookie doit être défini, on le stocke temporairement dans le contexte
297
+ // pour que le code appelant puisse le gérer.
298
+ if ($identity['newCookie']) {
299
+ // Cette propriété n'est pas standard, on la préfixe pour éviter les conflits.
300
+ $context->newCookieForResponse = $identity['newCookie'];
301
+ }
302
+
303
+ // Nettoyage périodique des données de l'appareil
304
+ if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
305
+ $deviceData['ips'] = [];
306
+ $deviceData['rapidChangeCount'] = 0;
307
+ }
308
+ $deviceData['lastUpdate'] = time() * 1000;
309
+
310
+ // --- Calcul des différents scores de suspicion ---
311
+
312
+ // Score d'incohérence du fingerprint (déplacé ici pour être avec les autres)
313
+ $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
314
+ $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
315
+ $inconsistencyScore = min(100.0, max(0.0, (1 - $consistencyScore) * 200));
316
+ if ($consistencyScore < ($this->securityConfig['similarityThreshold'] ?? 0.7)) {
317
+ $inconsistencyScore = 100.0;
318
+ }
319
+
320
+ $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
321
+
322
+ // Score des anomalies d'en-têtes
323
+ $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
324
+
325
+ // Score de spoofing TLS
326
+ $tlsSpoofing = RequestUtils::getTlsSpoofingScore($context);
327
+
328
+ // Score d'incohérence temporelle (attaque par rejeu)
329
+ $timeInconsistency = RequestUtils::getTimeInconsistencyScore($context);
330
+
331
+ // Score des incohérences entre couches (client vs serveur)
332
+ $crossLayerInconsistency = RequestUtils::getCrossLayerInconsistency($context);
333
+
334
+ // Score des patterns de requêtes (scraping, vélocité)
335
+ $requestPattern = RequestUtils::getRequestPatternScore($context, $deviceData, $this->securityConfig['patterns'] ?? []);
336
+
337
+ // Score des honeypots
338
+ $honeypot = RequestUtils::getHoneypotScore($context, $this->securityConfig['honeypot'] ?? []);
339
+
340
+ // Score des métriques comportementales client (souris, clavier)
341
+ $behavior = RequestUtils::getBehaviorScore($context);
342
+
343
+ // Score de détection de bot explicite (marqueurs d'automatisation)
344
+ $bot = RequestUtils::getBotScore($context);
345
+
346
+ // Score de variance des clics
347
+ $clickVariance = RequestUtils::getClickVarianceScore($context);
348
+
349
+ // Score de variance des clics
350
+ $clickVariance = RequestUtils::getClickVarianceScore($context);
351
+
352
+ // Score basé sur les listes de menaces (Threat Intelligence)
353
+ $threatIntel = RequestUtils::getThreatIntelScore($context, $this->securityConfig['threatIntel'] ?? []);
354
+
355
+ // Score d'incohérence des Client-Hints
356
+ $clientHintsInconsistency = RequestUtils::getClientHintsInconsistencyScore($context);
357
+
358
+ // NOUVEAU: Score de réputation du sous-réseau IP
359
+ $subnetScore = RequestUtils::getSubnetScore($context, $deviceId);
360
+
361
+ // Assemblage du vecteur de suspicion final
362
+ $suspicionVector = array_merge($suspicionVector, [
363
+ 'inconsistencyScore' => $inconsistencyScore,
364
+ 'historyScore' => $behavioral['historyScore'],
365
+ 'rotationScore' => $behavioral['rotationScore'],
366
+ 'headerAnomalyScore' => $headerAnomalies['headerAnomalyScore'],
367
+ 'tlsSpoofingScore' => $tlsSpoofing['tlsSpoofingScore'],
368
+ 'timeInconsistencyScore' => $timeInconsistency['timeInconsistencyScore'],
369
+ 'crossLayerInconsistencyScore' => $crossLayerInconsistency['crossLayerInconsistencyScore'],
370
+ 'requestPatternScore' => $requestPattern['requestPatternScore'], // Ce score est maintenant calculé
371
+ 'honeypotScore' => $honeypot['honeypotScore'],
372
+ 'behaviorScore' => $behavior['behaviorScore'],
373
+ 'botScore' => $bot['botScore'],
374
+ 'clickVarianceScore' => $clickVariance['clickVarianceScore'],
375
+ 'threatIntelScore' => $threatIntel['threatIntelScore'],
376
+ 'clientHintsInconsistencyScore' => $clientHintsInconsistency['clientHintsInconsistencyScore'],
377
+ 'subnetScore' => $subnetScore['subnetScore'],
378
+ ]);
379
+
380
+ // Sauvegarder l'état mis à jour de l'appareil dans le store
381
+ $store->set("device:{$deviceId}", $deviceData);
382
+
383
+ return $suspicionVector;
384
+ }
385
+
386
+ /**
387
+ * Traite une requête entrante et retourne une décision.
388
+ * @param RequestContext $context Le contexte de la requête.
389
+ * @return array{action: string, score: float, vector: array, status?: int, body?: mixed, cookie?: array, path?: string, newCookieForResponse?: array}
390
+ */
391
+ public function processRequest(RequestContext $context): array
392
+ {
393
+ // Initialiser le vecteur de suspicion pour éviter les erreurs de type.
394
+ $suspicionVector = [];
395
+
396
+ $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
397
+
398
+ // Parse GraphQL query if applicable
399
+ if ($context->path === '/graphql' && !empty($context->body)) {
400
+ $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
401
+ if ($gqlInfo) {
402
+ $context->graphqlOperation = $gqlInfo;
403
+ }
404
+ }
405
+
406
+ // 1. Vérifier les listes blanches
407
+ if ($this->checkAllowlists($context)) {
408
+ return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
409
+ }
410
+
411
+ // Initialiser le vecteur de suspicion
412
+ $thresholds = $this->securityConfig['thresholds'];
413
+
414
+ // 2. Gérer la soumission d'une solution de challenge (PRIORITÉ HAUTE)
415
+ $powNonce = $context->query['pow_nonce'] ?? null;
416
+ $isChallengeSubmission = $powNonce && (
417
+ isset($context->query['pow_solution']) ||
418
+ isset($context->query['pow_solution_cpu']) ||
419
+ (isset($context->query['pow_type']) && $context->query['pow_type'] === 'useful_work_task')
420
+ );
421
+ if ($isChallengeSubmission) {
422
+ $this->log('Challenge solution submitted', ['pow_type' => $context->query['pow_type'] ?? 'unknown', 'nonce' => $powNonce]);
423
+ $store = StoreManager::getStore();
424
+ $challengeContext = $store->get("secret:{$powNonce}");
425
+ $powType = $context->query['pow_type'] ?? null;
426
+
427
+ if ($challengeContext) {
428
+ $isValid = false;
429
+ $ticket = null;
430
+
431
+ // Vérification de la cohérence du fingerprint
432
+ $solverFingerprint = $context->query['pow_fp'] ?? RequestUtils::getCompositeDeviceHash($context);
433
+ $originalFingerprint = $challengeContext['fingerprint'] ?? '';
434
+ $similarity = FingerprintBuilder::compare($originalFingerprint, $solverFingerprint);
435
+ $similarityThreshold = $this->securityConfig['similarityThreshold'] ?? 0.95;
436
+
437
+ if ($similarity < $similarityThreshold) {
438
+ $this->log('Fingerprint mismatch - challenge solved on a different machine!', [
439
+ 'similarity' => round($similarity, 4),
440
+ 'threshold' => $similarityThreshold
441
+ ], 'warn');
442
+ $isValid = false;
443
+ } else {
444
+ // Le fingerprint est cohérent, on peut valider la solution
445
+ if ($powType === 'cpu_target' || $powType === 'cpu_mem') {
446
+ $cpuSolution = $context->query['pow_solution_cpu'] ?? $context->query['pow_solution'] ?? null;
447
+ if ($cpuSolution) {
448
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($context->clientIp, 3600000, $powNonce, $cpuSolution, $challengeContext);
449
+ $isValid = $ticket !== null;
450
+
451
+ if ($powType === 'cpu_mem') {
452
+ $memSolution = $context->query['pow_solution_mem'] ?? null;
453
+ $isMemValid = $memSolution ? ChallengeUtils::verifyMemoryPoW($powNonce, $memSolution, $challengeContext['memDifficulty'] ?? 0, $challengeContext['clientSecret'] ?? '') : false;
454
+ $isValid = $isValid && $isMemValid;
455
+ }
456
+ }
457
+ } elseif ($powType === 'useful_work_task') {
458
+ $problemId = $context->query['pow_problem_id'] ?? null;
459
+ $workResultJson = $context->query['pow_solution_work_result'] ?? null;
460
+ if ($problemId && $workResultJson) {
461
+ $workResult = json_decode($workResultJson, true);
462
+ $this->log('Verifying useful work solution.', [
463
+ 'problemId' => $problemId,
464
+ 'receivedData' => $workResult,
465
+ 'jsonLastError' => json_last_error_msg()
466
+ ]);
467
+ if (json_last_error() === JSON_ERROR_NONE) {
468
+ // @phpstan-ignore-next-line - L'instance est gérée par le singleton
469
+ $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($this->securityConfig['usefulWorkConfigPath'] ?? null, $store);
470
+ // FIX: La solution est directement le $workResult, pas une sous-propriété.
471
+ $problemManager->integrateSolution($problemId, $workResult);
472
+ $isValid = true;
473
+ // FIX: Générer un vrai ticket pour uPoW, comme pour un PoW normal.
474
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000; // 1 heure par défaut
475
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
476
+ $signature = hash_hmac('sha256', "{$context->clientIp}:{$expiry}", ChallengeUtils::getPowSecret());
477
+ $ticket = "{$expiry}:{$signature}";
478
+ }
479
+ }
480
+ }
481
+ }
482
+
483
+ if ($isValid) {
484
+ $store->delete("secret:{$powNonce}");
485
+ $ticketTtl = $this->securityConfig['ticketMaxAge'] ?? 3600000;
486
+ $this->log('Challenge solution valid - issuing ticket', ['ticketMaxAge' => $ticketTtl]);
487
+
488
+ return [
489
+ // ... (le reste de la logique de redirection)
490
+ 'action' => 'redirect',
491
+ 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
492
+ 'score' => 0.0,
493
+ 'action' => 'redirect',
494
+ 'path' => RequestUtils::cleanUrlFromPowParams($challengeContext['originalPath'] ?? '/', $context->query),
495
+ 'score' => 0.0,
496
+ 'vector' => ['challenge_solved' => 100],
497
+ 'cookie' => ['name' => 'pow_clearance', 'value' => $ticket, 'options' => ['httponly' => true, 'secure' => $this->isProduction, 'expires' => time() + ($ticketTtl / 1000), 'path' => '/']]
498
+ ];
499
+ }
500
+ }
501
+ // Si la solution est invalide ou le nonce est expiré, on pénalise fortement pour la suite.
502
+ $this->log('Challenge solution invalid or context expired', ['nonce' => $powNonce], 'warn');
503
+ $suspicionVector['honeypotScore'] = 100.0;
504
+ }
505
+
506
+ // 3. Vérifier un ticket existant
507
+ $hasValidTicket = false;
508
+ $powCookie = $context->cookies['pow_clearance'] ?? null;
509
+ if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie)) {
510
+ $hasValidTicket = true;
511
+ // On ne retourne pas tout de suite pour permettre le re-challenge
512
+ // $this->log('Valid clearance ticket found');
513
+ // return ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100]];
514
+ }
515
+
516
+ // 4. Calculer le vecteur et le score de suspicion
517
+ // Résoudre l'identité et vérifier le statut "condamné"
518
+ $store = StoreManager::getStore();
519
+ $identity = $this->resolveRequestIdentity($context, $suspicionVector);
520
+ $deviceId = $identity['deviceId'];
521
+ $deviceData = $identity['deviceData'];
522
+ if ($deviceData && ($deviceData['condemned'] ?? false)) {
523
+ $this->log('Device condemned - blocking request', ['deviceId' => $deviceId], 'warn');
524
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
525
+ if ($this->dryRun) {
526
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
527
+ $decision['intendedAction'] = $decision['action'];
528
+ $decision['action'] = 'next';
529
+ unset($decision['status'], $decision['body']);
530
+ }
531
+ return $decision;
532
+ }
533
+
534
+ $suspicionVector = $this->getSuspicionVector($context, $suspicionVector);
535
+ $finalScore = $this->calculateFinalScore($suspicionVector);
536
+ $this->log('Suspicion vector and final score calculated', [
537
+ 'finalScore' => round($finalScore, 2),
538
+ 'vector' => $suspicionVector
539
+ ]);
540
+
541
+ // Mettre à jour les métriques du sous-réseau après le calcul du score final
542
+ if ($finalScore > ($thresholds['low'] ?? 20)) {
543
+ RequestUtils::updateSubnetMetrics($context, $deviceId, $finalScore);
544
+ }
545
+
546
+ // Logique pour challenger les nouveaux appareils (déplacée ici pour avoir le score final)
547
+ $isNewDevice = $identity['newCookie'] !== null;
548
+ if ($isNewDevice && ($this->securityConfig['challengeNewDevices'] ?? false) && $finalScore < $thresholds['low']) {
549
+ $this->log('New device - enforcing minimum challenge score', [
550
+ 'originalScore' => round($finalScore, 2),
551
+ 'enforcedScore' => (float)$thresholds['low']
552
+ ]);
553
+ $finalScore = (float)$thresholds['low'];
554
+ }
555
+
556
+ // Vérifier les URL pièges (après calcul du score)
557
+ $lastNonce = $deviceData['lastChallengeNonce'] ?? null;
558
+ if ($lastNonce && ChallengeUtils::verifyTrapUrl($context->path, $context->query['sig'] ?? '', $lastNonce)) {
559
+ if ($this->logger) {
560
+ $this->logger->log('info', 'trap_triggered', ['deviceId' => $deviceId, 'score' => 100, 'path' => $context->path, 'vector' => ['honeypotScore' => 100]]);
561
+ }
562
+ $this->log('Honeypot trap URL triggered - condemning device', ['path' => $context->path, 'deviceId' => $deviceId]);
563
+ $deviceData['condemned'] = true; // @phpstan-ignore-line
564
+ $store->set("device:{$deviceId}", $deviceData);
565
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => 100, 'vector' => ['honeypotScore' => 100]];
566
+ if ($this->dryRun) {
567
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
568
+ $decision['intendedAction'] = $decision['action'];
569
+ $decision['action'] = 'next';
570
+ unset($decision['status'], $decision['body']);
571
+ }
572
+ return $decision;
573
+ }
574
+
575
+ // 5. Prendre une décision basée sur le score - Vérifier le blocage d'abord.
576
+ $blockThreshold = $thresholds['block'] ?? 95;
577
+ if ($finalScore >= $blockThreshold) {
578
+ if ($this->logger) {
579
+ $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
580
+ }
581
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
582
+ if ($this->dryRun) {
583
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
584
+ $decision['intendedAction'] = $decision['action'];
585
+ $decision['action'] = 'next';
586
+ unset($decision['status'], $decision['body']);
587
+ }
588
+ $response = $decision;
589
+ } else {
590
+ // Logique de re-challenge
591
+ // Si un nonce est présent mais que ce n'est pas une soumission de solution valide, c'est une sonde.
592
+ if ($powNonce && !$isChallengeSubmission) {
593
+ $this->log('Honeypot probe detected - blocking request', ['path' => $context->path, 'pow_nonce' => $powNonce]);
594
+ $suspicionVector['honeypotScore'] = 100.0;
595
+ $finalScore = $this->calculateFinalScore($suspicionVector); // Recalculate score
596
+ $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
597
+ // Apply dry run logic here as well
598
+ if ($this->dryRun) {
599
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
600
+ $decision['intendedAction'] = $decision['action'];
601
+ $decision['action'] = 'next';
602
+ unset($decision['status'], $decision['body']);
603
+ }
604
+ return $decision;
605
+ }
606
+
607
+ $highThreshold = $thresholds['high'] ?? 75;
608
+ $mustReChallenge = $finalScore >= $highThreshold && $hasValidTicket;
609
+
610
+ $lowThreshold = $thresholds['low'] ?? 20;
611
+ if (($finalScore >= $lowThreshold && !$hasValidTicket) || $mustReChallenge) {
612
+ if ($mustReChallenge) {
613
+ $this->log('High suspicion score detected - overriding valid ticket to re-issue challenge', ['finalScore' => $finalScore, 'deviceId' => $deviceId]);
614
+ }
615
+
616
+ $decision = ['action' => 'challenge', 'score' => $finalScore, 'vector' => $suspicionVector, 'status' => 403];
617
+
618
+ if ($this->dryRun) {
619
+ $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
620
+ $decision['intendedAction'] = $decision['action'];
621
+ $decision['action'] = 'next';
622
+ unset($decision['status']);
623
+ return $decision;
624
+ }
625
+
626
+ $this->log('Suspicious request - selecting challenge type', ['finalScore' => $finalScore]);
627
+
628
+ $nonce = bin2hex(random_bytes(16));
629
+ $clientSecret = bin2hex(random_bytes(16));
630
+
631
+ // --- NOUVELLE LOGIQUE uPoW ---
632
+ $shouldUseUsefulWork = ($this->securityConfig['enableUsefulWork'] ?? false) && (
633
+ ($this->securityConfig['forceUsefulWork'] ?? false) || (random_int(0, 255) / 255) > 0.5
634
+ );
635
+
636
+ // Déterminer si c'est une requête API avant de choisir le type de challenge
637
+ $isApiRequest = false;
638
+ if (isset($this->securityConfig['isApiRequest']) && is_callable($this->securityConfig['isApiRequest'])) {
639
+ $isApiRequest = ($this->securityConfig['isApiRequest'])($context);
640
+ }
641
+
642
+ if ($shouldUseUsefulWork) {
643
+ $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
644
+ $problemManager = ProblemManager::getInstance($this->securityConfig['usefulWorkConfigPath'] ?? null, $store);
645
+ $work = $problemManager->dispatchWork($finalScore);
646
+
647
+ if ($work !== null) {
648
+ $store->set("secret:{$nonce}", ['clientSecret' => $clientSecret, 'originalPath' => $context->path], 300);
649
+ $challengePayload = [
650
+ 'challenge' => [
651
+ 'type' => 'useful_work_task',
652
+ 'nonce' => $nonce,
653
+ 'clientSecret' => $clientSecret,
654
+ 'usefulWorkTask' => [
655
+ 'problemId' => $work['problemId'],
656
+ 'task' => $work['task']
657
+ ]
658
+ ]
659
+ ];
660
+ $decision['body'] = $challengePayload;
661
+ return $decision;
662
+ } else {
663
+ // This case handles when uPoW is enabled but dispatching a task fails (e.g., config not found).
664
+ // We log it and fall through to the standard PoW challenge.
665
+ $this->log('Useful work dispatch failed, falling back to standard PoW.', [], 'warn');
666
+ $shouldUseUsefulWork = false; // Explicitly disable for this request
667
+ }
668
+ }
669
+
670
+ // --- FIN DE LA LOGIQUE uPoW (le reste est le fallback) ---
671
+
672
+ $suspicionFactor = ($finalScore - $lowThreshold) / (($thresholds['high'] ?? 75) - $lowThreshold);
673
+ $suspicionFactor = max(0, min(1.5, $suspicionFactor));
674
+
675
+ $cpuChallengeDetails = [
676
+ 'type' => 'cpu_target',
677
+ 'nonce' => $nonce,
678
+ 'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
679
+ 'path' => $context->path,
680
+ ];
681
+
682
+ $memActivationFactor = max(0, ($suspicionFactor - 0.25) / 0.75);
683
+ $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
684
+
685
+ $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
686
+ $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
687
+
688
+ $challengeContext = [
689
+ 'clientSecret' => $clientSecret,
690
+ 'cpuTarget' => $cpuChallengeDetails['target'],
691
+ 'suspicionScore' => $finalScore,
692
+ 'fingerprint' => $originalFingerprint,
693
+ 'memDifficulty' => $memDifficulty,
694
+ 'baseBlock' => $baseBlock,
695
+ 'originalPath' => $context->path,
696
+ ];
697
+
698
+ $store->set("secret:{$nonce}", $challengeContext, $this->securityConfig['challengeTtl'] ?? 300);
699
+
700
+ // Associer le nonce au device pour la vérification des URL pièges
701
+ if ($deviceData) {
702
+ $deviceData['lastChallengeNonce'] = $nonce;
703
+ $store->set("device:{$deviceId}", $deviceData); // @phpstan-ignore-line
704
+ }
705
+
706
+ $trapUrls = [ChallengeUtils::generateTrapUrl($nonce), ChallengeUtils::generateTrapUrl($nonce)];
707
+ $this->log('Challenge issued', ['nonce' => $nonce, 'ttl' => $this->securityConfig['challengeTtl'] ?? 300]);
708
+
709
+ if ($this->logger) {
710
+ $this->logger->log('info', 'challenge_issued', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
711
+ }
712
+
713
+ // Pour les API, retourner un challenge JSON
714
+ if ($isApiRequest) {
715
+ $challengePayload = [
716
+ 'challenge' => [
717
+ 'type' => 'cpu_mem',
718
+ 'nonce' => $nonce,
719
+ 'clientSecret' => $clientSecret,
720
+ 'cpuTarget' => $cpuChallengeDetails['target'],
721
+ 'memDifficulty' => $memDifficulty,
722
+ 'baseBlock' => array_values(unpack('C*', $baseBlock)), // Envoyer comme un tableau d'octets
723
+ ]
724
+ ];
725
+ $decision['body'] = $challengePayload;
726
+ } else {
727
+ // Pour les navigateurs, retourner une page HTML
728
+ $pageBody = ChallengeUtils::generateCombinedPoWChallengePage(
729
+ $cpuChallengeDetails, $memDifficulty, $clientSecret,
730
+ $this->securityConfig, $trapUrls, $originalFingerprint
731
+ );
732
+ $decision['body'] = $pageBody;
733
+ }
734
+ $response = $decision;
735
+ } elseif ($hasValidTicket) {
736
+ // Si on arrive ici avec un ticket valide et un score bas, on autorise
737
+ $this->log('Valid clearance ticket found and score is low - allowing request');
738
+ $response = ['action' => 'next', 'score' => 0.0, 'vector' => ['ticket_valid' => 100], 'intendedAction' => 'next'];
739
+ } else {
740
+ // 6. Si le score est bas et qu'il n'y a pas de ticket, autoriser la requête
741
+ $this->log('Request passed - no challenge required', ['finalScore' => $finalScore]);
742
+ if ($this->logger) {
743
+ $this->logger->log('info', 'request_passed', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
744
+ }
745
+ $response = ['action' => 'next', 'score' => $finalScore, 'vector' => $suspicionVector, 'intendedAction' => 'next'];
746
+ }
747
+ }
748
+
749
+ // Si un nouveau cookie d'identification a été généré, on l'ajoute à la réponse.
750
+ if (isset($context->newCookieForResponse)) {
751
+ $response['newCookieForResponse'] = $context->newCookieForResponse;
752
+ }
753
+
754
+ return $response;
755
+ }
756
+
757
+ /**
758
+ * Vérifie si l'opération GraphQL correspond à une entrée dans la liste blanche.
759
+ */
760
+ private function isGraphqlOperationInAllowlist(?string $operationType, ?string $operationName): bool
761
+ {
762
+ if (empty($operationType) || empty($operationName)) {
763
+ return false;
764
+ }
765
+
766
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
767
+ $graphqlRule = null;
768
+ foreach ($whitelistRules as $rule) {
769
+ if (($rule['type'] ?? '') === 'graphql_operation_allowlist') {
770
+ $graphqlRule = $rule;
771
+ break;
772
+ }
773
+ }
774
+
775
+ if (empty($graphqlRule['entries'])) {
776
+ return false;
777
+ }
778
+
779
+ foreach ($graphqlRule['entries'] as $entry) {
780
+ [$entryType, $entryName] = explode(':', $entry, 2);
781
+ if ($entryType !== $operationType) continue;
782
+
783
+ if ($entryName === $operationName || $entryName === '*') return true;
784
+
785
+ if (str_ends_with($entryName, '*') && str_starts_with($operationName, substr($entryName, 0, -1))) return true;
786
+ }
787
+
788
+ return false;
789
+ }
790
+
791
+ /**
792
+ * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
793
+ * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
794
+ */
795
+ private function verifyWhitelistedBot(RequestContext $context): bool
796
+ {
797
+ $whitelistRules = $this->securityConfig['whitelist'] ?? [];
798
+ $botRules = array_filter($whitelistRules, fn($rule) => isset($rule['hostnameSuffix']));
799
+ if (empty($botRules)) {
800
+ return false;
801
+ }
802
+
803
+ $userAgent = $context->getHeader('user-agent') ?? '';
804
+ $matchedRule = null;
805
+ foreach ($botRules as $rule) {
806
+ if (isset($rule['userAgent']) && preg_match('/' . $rule['userAgent'] . '/', $userAgent)) {
807
+ $matchedRule = $rule;
808
+ break;
809
+ }
810
+ }
811
+ if ($matchedRule === null) {
812
+ return false;
813
+ }
814
+
815
+ $store = StoreManager::getStore();
816
+ $cacheKey = "ip-whitelist:{$context->clientIp}";
817
+ $cachedStatus = $store->get($cacheKey);
818
+
819
+ if ($cachedStatus === 'verified') return true;
820
+ if ($cachedStatus === 'failed') return false;
821
+
822
+ try {
823
+ // 1. Reverse DNS lookup. gethostbyaddr peut être lent, mais c'est la méthode standard.
824
+ // @ pour supprimer les warnings si l'IP n'a pas de PTR record.
825
+ $hostname = @gethostbyaddr($context->clientIp);
826
+ if ($hostname === false || $hostname === $context->clientIp) {
827
+ $store->set($cacheKey, 'failed', 86400);
828
+ return false;
829
+ }
830
+
831
+ $validHostname = null;
832
+ if (str_ends_with($hostname, $matchedRule['hostnameSuffix'])) {
833
+ $validHostname = $hostname;
834
+ }
835
+
836
+ if ($validHostname === null) {
837
+ $store->set($cacheKey, 'failed', 86400);
838
+ return false;
839
+ }
840
+
841
+ // 2. Forward DNS lookup
842
+ $addresses = array_merge(dns_get_record($validHostname, DNS_A) ?: [], dns_get_record($validHostname, DNS_AAAA) ?: []);
843
+ $ips = array_column($addresses, 'ip');
844
+
845
+ if (in_array($context->clientIp, $addresses)) {
846
+ $store->set($cacheKey, 'verified', 86400);
847
+ return true;
848
+ }
849
+ } catch (\Exception $e) { /* DNS errors */ }
850
+
851
+ $store->set($cacheKey, 'failed', 86400);
852
+ return false;
853
+ }
854
+
855
+ /**
856
+ * @internal For testing purposes only.
857
+ */
858
+ public function getProblems(): array
859
+ {
860
+ $problemManager = ProblemManager::getInstance();
861
+ return $problemManager->getProblems();
862
+ }
863
+ }