@anonympins/fingerprint 0.7.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -2
  4. package/public/anonympins-bot-mitigation-pow.zip +0 -0
  5. package/src/js/asn-lookup.js +223 -0
  6. package/src/js/fingerprint.client.js +1784 -1772
  7. package/src/js/fingerprint.client.obfuscated.js +1 -1
  8. package/src/js/fingerprint.js +236 -40
  9. package/src/js/fingerprint.utils.js +252 -2
  10. package/src/js/pow.worker.js +2 -2
  11. package/src/js/tests/asn-lookup.test.js +57 -0
  12. package/src/js/tests/cross_parity.test.js +3 -3
  13. package/src/js/tests/dns-circuit-breaker.test.js +6 -6
  14. package/src/js/tests/fingerprint.builder.test.js +76 -78
  15. package/src/js/tests/fingerprint.client.test.js +16 -17
  16. package/src/js/tests/fingerprint.test.js +19 -12
  17. package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
  18. package/src/js/tests/library.test.js +94 -95
  19. package/src/js/tests/obfuscation.test.js +8 -8
  20. package/src/js/tests/quicFingerprint.test.js +47 -0
  21. package/src/js/tests/tcpFingerprint.test.js +4 -4
  22. package/src/js/upow-model-task.js +155 -112
  23. package/src/php/AsnLookupEngine.php +173 -0
  24. package/src/php/Challenge/ChallengeUtils.php +430 -117
  25. package/src/php/Config/SecurityProfiles.php +75 -62
  26. package/src/php/DirectFingerprint.php +80 -13
  27. package/src/php/FingerprintBuilder.php +13 -13
  28. package/src/php/FingerprintClient.php +6 -6
  29. package/src/php/FingerprintEngine.php +2141 -1837
  30. package/src/php/Ja3AnomalyDetector.php +22 -22
  31. package/src/php/NetworkProfile.php +68 -0
  32. package/src/php/Optimization/FunctionRegistry.php +4 -4
  33. package/src/php/Optimization/Optimization.php +6 -6
  34. package/src/php/ProblemManager.php +12 -2
  35. package/src/php/RequestContext.php +11 -11
  36. package/src/php/Store/InMemoryStore.php +96 -66
  37. package/src/php/Store/MongoDbStore.php +2 -2
  38. package/src/php/Store/RedisStore.php +1 -1
  39. package/src/php/Store/StoreManager.php +48 -35
  40. package/src/php/Tests/AsnLookupTest.php +61 -0
  41. package/src/php/Tests/ChallengeUtilsTest.php +31 -0
  42. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  43. package/src/php/Tests/FingerprintClientTest.php +70 -70
  44. package/src/php/Tests/FingerprintEngineTest.php +4 -2
  45. package/src/php/Tests/PowTest.php +37 -39
  46. package/src/php/Tests/QuicFingerprintTest.php +115 -53
  47. package/src/php/Tests/ThreatIntelTest.php +2 -2
  48. package/src/php/Utils/BigInt.php +202 -202
  49. package/src/php/Utils/BlockList.php +13 -13
  50. package/src/php/Utils/Env.php +7 -2
  51. package/src/php/Utils/Logger.php +19 -1
  52. package/src/php/Utils/MaliciousPatterns.php +6 -6
  53. package/src/php/Utils/MetricsManager.php +209 -209
  54. package/src/php/Utils/RequestUtils.php +348 -18
  55. package/src/php/Utils/TLSClientHelloParser.php +385 -369
  56. package/src/php/WordPress/WpDbStore.php +34 -7
  57. package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1222 -0
  58. package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
  59. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
  60. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -0
  61. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  62. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +297 -261
  63. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  64. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
  65. package/src/php/WordPress/package.php +88 -99
  66. package/src/php/WordPress/readme.txt +28 -6
  67. package/src/php/bin/auto-tune.php +1 -1
  68. package/public/fingerprint-anti-bot.zip +0 -0
@@ -8,22 +8,36 @@ use Anonympins\Fingerprint\Store\StoreManager;
8
8
  use Anonympins\Fingerprint\FingerprintBuilder;
9
9
  use Anonympins\Fingerprint\Utils\BigInt;
10
10
  use Anonympins\Fingerprint\Utils\RequestUtils;
11
- use Anonympins\Fingerprint\Utils\Env;
11
+ use Anonympins\Fingerprint\Utils\Env;
12
12
 
13
13
  /**
14
- * Classe utilitaire pour la génération et la vérification des challenges Proof-of-Work.
14
+ * Utility class for generating and verifying Proof-of-Work challenges.
15
15
  */
16
16
  class ChallengeUtils
17
17
  {
18
- /** @var array<string, float> Cache local des floats convertis pour éviter les appels système pack/unpack */
18
+ /** @var array<string, float> Local cache of converted floats to avoid repeated pack/unpack system calls */
19
19
  private static array $froundCache = [];
20
20
 
21
+ /**
22
+ * Emulates JavaScript's Math.fround: rounds a float to the nearest 32-bit
23
+ * single-precision float. Uses a local cache to avoid repeated pack/unpack calls.
24
+ *
25
+ * @param float $value The float value to round.
26
+ * @return float The value rounded to single precision.
27
+ */
21
28
  private static function fround(float $value): float
22
29
  {
23
30
  $key = (string)$value;
24
31
  return self::$froundCache[$key] ?? (self::$froundCache[$key] = unpack('f', pack('f', $value))[1]);
25
32
  }
26
33
 
34
+ /**
35
+ * Hashes a string seed into a normalized float between 0 and 1
36
+ * using a simple 32-bit rolling hash (similar to Java's String.hashCode).
37
+ *
38
+ * @param string $seed The seed string to hash.
39
+ * @return float A pseudo-random float in the range [0, 1).
40
+ */
27
41
  public static function hashSeedToFloat(string $seed): float
28
42
  {
29
43
  $hash = 0;
@@ -36,6 +50,15 @@ class ChallengeUtils
36
50
  return abs($hash % 1000000) / 1000000;
37
51
  }
38
52
 
53
+ /**
54
+ * Derives a set of 4 unique sample indices from the client IP and a secret,
55
+ * using an HMAC-SHA256 over the client IP and a 5-minute time window.
56
+ * This makes the indices deterministic but rotating over time.
57
+ *
58
+ * @param string $clientIp The client IP address.
59
+ * @param string $secret The shared secret used for the HMAC.
60
+ * @return array<int> An array of 4 unique indices in the range [0, 63].
61
+ */
39
62
  public static function deriveSampleIndices(string $clientIp, string $secret): array
40
63
  {
41
64
  $timeWindow = (int)floor(time() / (60 * 5));
@@ -56,6 +79,19 @@ class ChallengeUtils
56
79
  return $indices;
57
80
  }
58
81
 
82
+ /**
83
+ * Verifies a GPU Proof-of-Work solution based on the logistic map
84
+ * (chaotic iteration x_{n+1} = r * x_n * (1 - x_n) with r = 3.9999).
85
+ * Only a subset of the 64 values is recomputed, using indices derived
86
+ * from the client IP and a shared secret.
87
+ *
88
+ * @param string $seed The challenge seed.
89
+ * @param int $iterations The number of logistic map iterations per value.
90
+ * @param string $solution A comma-separated string of 64 float values.
91
+ * @param string $clientIp The client IP used to derive the sample indices.
92
+ * @param string $secret The secret used to derive the sample indices.
93
+ * @return bool True if the sampled values match, false otherwise.
94
+ */
59
95
  public static function verifyGpuPow(string $seed, int $iterations, string $solution, string $clientIp = '127.0.0.1', string $secret = 'gpu-pow-salt'): bool
60
96
  {
61
97
  $values = explode(',', $solution);
@@ -82,6 +118,7 @@ class ChallengeUtils
82
118
  return true;
83
119
  }
84
120
 
121
+ /** Templates used to generate signed trap URLs that lure malicious crawlers. */
85
122
  private const TRAP_URL_TEMPLATES = [
86
123
  '/includes/config-{RANDOM}.php',
87
124
  '/.env.{RANDOM}',
@@ -92,6 +129,14 @@ class ChallengeUtils
92
129
  '/.git/config_{RANDOM}'
93
130
  ];
94
131
 
132
+ /**
133
+ * Emulates a 32-bit signed integer multiplication (like Math.imul in JS),
134
+ * handling overflow correctly.
135
+ *
136
+ * @param int $a The first operand.
137
+ * @param int $b The second operand.
138
+ * @return int The 32-bit signed result of a * b.
139
+ */
95
140
  private static function imul(int $a, int $b): int
96
141
  {
97
142
  $ah = ($a >> 16) & 0xffff;
@@ -103,11 +148,21 @@ class ChallengeUtils
103
148
  return (($hi << 16) | ($lo & 0xffff)) | 0;
104
149
  }
105
150
 
151
+ /**
152
+ * Deterministically generates a 1024-byte block of pseudo-random data
153
+ * from a seed and a block index, using a rolling hash (cyrb53-based).
154
+ * This is the building block for the proof-of-space challenge.
155
+ *
156
+ * @param string $seed The seed string.
157
+ * @param int $blockIndex The index of the block to generate.
158
+ * @param int $blockSize The size of the block in bytes (default 1024).
159
+ * @return string The raw binary block content.
160
+ */
106
161
  private static function generateBlock(string $seed, int $blockIndex, int $blockSize = 1024): string
107
162
  {
108
163
  $block = str_repeat("\x00", $blockSize);
109
164
  $h = FingerprintBuilder::cyrb53($seed . ":" . $blockIndex);
110
-
165
+
111
166
  $h_int = (int)bcmod($h, '4294967296');
112
167
  for ($i = 0; $i < $blockSize; $i++) {
113
168
  $h_int = self::imul($h_int ^ $i, 1597334677);
@@ -116,6 +171,15 @@ class ChallengeUtils
116
171
  return $block;
117
172
  }
118
173
 
174
+ /**
175
+ * Registers a cooperative node in the store, keyed by its IP subnet.
176
+ * Entries older than 2 minutes are pruned before adding the new node.
177
+ *
178
+ * @param string $clientIp The client IP (used to compute the subnet).
179
+ * @param string $nodeId The unique node identifier.
180
+ * @param string $seed The node's seed.
181
+ * @return void
182
+ */
119
183
  public static function registerCooperativeNode(string $clientIp, string $nodeId, string $seed): void
120
184
  {
121
185
  $subnet = RequestUtils::getIpSubnet($clientIp);
@@ -125,20 +189,28 @@ class ChallengeUtils
125
189
  $store = StoreManager::getStore();
126
190
  $key = "coop-pospace:subnet:{$subnet}";
127
191
  $nodes = $store->get($key) ?? [];
128
-
192
+
129
193
  $now = time();
130
- // Nettoyage des nœuds expirés (vieux de plus de 2 minutes)
194
+ // Clean up expired nodes (older than 2 minutes)
131
195
  $nodes = array_filter($nodes, fn($n) => ($now - $n['timestamp']) < 120);
132
-
196
+
133
197
  $nodes[$nodeId] = [
134
198
  'nodeId' => $nodeId,
135
199
  'seed' => $seed,
136
200
  'timestamp' => $now
137
201
  ];
138
-
202
+
139
203
  $store->set($key, $nodes, 120);
140
204
  }
141
205
 
206
+ /**
207
+ * Finds a random active peer node in the same subnet as the client,
208
+ * excluding a given node ID.
209
+ *
210
+ * @param string $clientIp The client IP (used to compute the subnet).
211
+ * @param string $excludeNodeId The node ID to exclude from the results.
212
+ * @return array|null The peer node data, or null if none is found.
213
+ */
142
214
  public static function findPeerInSubnet(string $clientIp, string $excludeNodeId): ?array
143
215
  {
144
216
  $subnet = RequestUtils::getIpSubnet($clientIp);
@@ -148,7 +220,7 @@ class ChallengeUtils
148
220
  $store = StoreManager::getStore();
149
221
  $key = "coop-pospace:subnet:{$subnet}";
150
222
  $nodes = $store->get($key) ?? [];
151
-
223
+
152
224
  $now = time();
153
225
  $activePeers = [];
154
226
  foreach ($nodes as $id => $node) {
@@ -156,14 +228,25 @@ class ChallengeUtils
156
228
  $activePeers[] = $node;
157
229
  }
158
230
  }
159
-
231
+
160
232
  if (empty($activePeers)) {
161
233
  return null;
162
234
  }
163
-
235
+
164
236
  return $activePeers[array_rand($activePeers)];
165
237
  }
166
238
 
239
+ /**
240
+ * Handles cooperative peer-to-peer operations (federation threat intel sharing,
241
+ * node registration, peer discovery, WebRTC signaling, block requests/responses).
242
+ * Enforces cooperative signature verification for every operation except
243
+ * the federation threat intel sharing path.
244
+ *
245
+ * @param array $params The request parameters (includes `coop_op`).
246
+ * @param string $clientIp The client IP address.
247
+ * @param array $config Additional configuration (federated peers, thresholds, keys).
248
+ * @return array|null The response payload, or null if `coop_op` is missing.
249
+ */
167
250
  public static function handleCooperativeRequest(array $params, string $clientIp = '127.0.0.1', array $config = []): ?array
168
251
  {
169
252
  $op = $params['coop_op'] ?? null;
@@ -252,7 +335,7 @@ class ChallengeUtils
252
335
  return ['error' => 'Missing node_id'];
253
336
  }
254
337
 
255
- // --- VÉRIFICATION DE LA SIGNATURE COOPÉRATIVE ---
338
+ // --- COOPERATIVE SIGNATURE VERIFICATION ---
256
339
  $challengeContext = $store->get("secret:{$nodeId}");
257
340
  if (!$challengeContext || empty($challengeContext['clientSecret'])) {
258
341
  return ['error' => 'Invalid or expired node_id'];
@@ -270,19 +353,19 @@ class ChallengeUtils
270
353
  $expectedMsg = "{$clientSecret}:find_peer:{$nodeId}";
271
354
  break;
272
355
  case 'webrtc_signal':
273
- $expectedMsg = "{$clientSecret}:webrtc_signal:{$nodeId}:" . ($params['target_peer_id'] ?? '') . ":" . ($params['signal_type'] ?? '') . ":" . ($params['signal_data'] ?? '');
356
+ $expectedMsg = "{$clientSecret}:webrtc_signal:{$nodeId}:" . (isset($params['target_peer_id']) ? self::sanitizeString($params['target_peer_id']) : '') . ":" . (isset($params['signal_type']) ? self::sanitizeString($params['signal_type']) : '') . ":" . (isset($params['signal_data']) ? self::sanitizeString($params['signal_data']) : '');
274
357
  break;
275
358
  case 'poll_signals':
276
359
  $expectedMsg = "{$clientSecret}:poll_signals:{$nodeId}";
277
360
  break;
278
361
  case 'request_peer_block':
279
- $expectedMsg = "{$clientSecret}:request_peer_block:{$nodeId}:" . ($params['peer_id'] ?? '') . ":" . ($params['block_idx'] ?? '0') . ":" . ($params['req_id'] ?? '');
362
+ $expectedMsg = "{$clientSecret}:request_peer_block:{$nodeId}:" . (isset($params['peer_id']) ? self::sanitizeString($params['peer_id']) : '') . ":" . ($params['block_idx'] ?? '0') . ":" . (isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '');
280
363
  break;
281
364
  case 'poll_requests':
282
365
  $expectedMsg = "{$clientSecret}:poll_requests:{$nodeId}";
283
366
  break;
284
367
  case 'respond_block':
285
- $expectedMsg = "{$clientSecret}:respond_block:{$nodeId}:" . ($params['requester_id'] ?? '') . ":" . ($params['req_id'] ?? '') . ":" . ($params['block_data'] ?? '');
368
+ $expectedMsg = "{$clientSecret}:respond_block:{$nodeId}:" . (isset($params['requester_id']) ? self::sanitizeString($params['requester_id']) : '') . ":" . (isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '') . ":" . (isset($params['block_data']) ? self::sanitizeString($params['block_data']) : '');
286
369
  break;
287
370
  case 'poll_response':
288
371
  $expectedMsg = "{$clientSecret}:poll_response:{$nodeId}:" . ($params['req_id'] ?? '');
@@ -295,12 +378,12 @@ class ChallengeUtils
295
378
  if (!hash_equals($expectedSig, $coopSig)) {
296
379
  return ['error' => 'Invalid cooperative signature'];
297
380
  }
298
- // --- FIN DE LA VÉRIFICATION ---
381
+ // --- END OF VERIFICATION ---
299
382
 
300
383
  switch ($op) {
301
384
  case 'register':
302
385
  $clientIp = self::getSanitizedServerVar('REMOTE_ADDR', '127.0.0.1');
303
- $seed = $params['seed'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
386
+ $seed = isset($params['seed']) ? self::sanitizeString($params['seed']) : '';
304
387
  self::registerCooperativeNode($clientIp, $nodeId, $seed);
305
388
  return ['status' => 'registered'];
306
389
 
@@ -312,9 +395,9 @@ class ChallengeUtils
312
395
  return ['status' => 'no_peers'];
313
396
 
314
397
  case 'webrtc_signal':
315
- $targetPeerId = $params['target_peer_id'] ?? '';
316
- $signalType = $params['signal_type'] ?? '';
317
- $signalData = $params['signal_data'] ?? '';
398
+ $targetPeerId = isset($params['target_peer_id']) ? self::sanitizeString($params['target_peer_id']) : '';
399
+ $signalType = isset($params['signal_type']) ? self::sanitizeString($params['signal_type']) : '';
400
+ $signalData = isset($params['signal_data']) ? self::sanitizeString($params['signal_data']) : '';
318
401
  if (empty($targetPeerId) || empty($signalType) || empty($signalData)) {
319
402
  return ['error' => 'Invalid parameters'];
320
403
  }
@@ -337,13 +420,13 @@ class ChallengeUtils
337
420
  return ['status' => 'ok', 'signals' => $signals];
338
421
 
339
422
  case 'request_peer_block':
340
- $peerId = $params['peer_id'] ?? '';
423
+ $peerId = isset($params['peer_id']) ? self::sanitizeString($params['peer_id']) : '';
341
424
  $blockIdx = (int)($params['block_idx'] ?? 0);
342
- $requestId = $params['req_id'] ?? '';
425
+ $requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
343
426
  if (empty($peerId) || empty($requestId)) {
344
427
  return ['error' => 'Invalid parameters'];
345
428
  }
346
-
429
+
347
430
  $queueKey = "coop-mailbox:queue:{$peerId}";
348
431
  $requests = $store->get($queueKey) ?? [];
349
432
  $requests[] = [
@@ -361,19 +444,19 @@ class ChallengeUtils
361
444
  return ['requests' => $requests];
362
445
 
363
446
  case 'respond_block':
364
- $requesterId = $params['requester_id'] ?? '';
365
- $requestId = $params['req_id'] ?? '';
366
- $blockData = $params['block_data'] ?? '';
447
+ $requesterId = isset($params['requester_id']) ? self::sanitizeString($params['requester_id']) : '';
448
+ $requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
449
+ $blockData = isset($params['block_data']) ? self::sanitizeString($params['block_data']) : '';
367
450
  if (empty($requesterId) || empty($requestId)) {
368
451
  return ['error' => 'Invalid parameters'];
369
452
  }
370
-
453
+
371
454
  $responseKey = "coop-mailbox:res:{$requesterId}:{$requestId}";
372
455
  $store->set($responseKey, ['block_data' => $blockData], 30);
373
456
  return ['status' => 'delivered'];
374
457
 
375
458
  case 'poll_response':
376
- $requestId = $params['req_id'] ?? '';
459
+ $requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
377
460
  $responseKey = "coop-mailbox:res:{$nodeId}:{$requestId}";
378
461
  $data = $store->get($responseKey);
379
462
  if ($data) {
@@ -385,6 +468,18 @@ class ChallengeUtils
385
468
  return null;
386
469
  }
387
470
 
471
+ /**
472
+ * Generates a proof-of-space challenge for a client, including random block
473
+ * queries. If a peer is found in the same subnet, a cooperative component is
474
+ * added to the challenge (peer ID and a random block index).
475
+ *
476
+ * @param string $clientIp The client IP address.
477
+ * @param string $nonce The challenge nonce.
478
+ * @param float $suspicionFactor The suspicion factor for the request.
479
+ * @param string $originalUrl The original requested URL (used as redirect path).
480
+ * @param array $securityConfig The security configuration array.
481
+ * @return array The challenge details.
482
+ */
388
483
  public static function generateSpaceChallenge(string $clientIp, string $nonce, float $suspicionFactor, string $originalUrl, array $securityConfig): array
389
484
  {
390
485
  $pospaceConfig = $securityConfig['pospace'] ?? [];
@@ -408,12 +503,12 @@ class ChallengeUtils
408
503
  'path' => $originalUrl
409
504
  ];
410
505
 
411
- // Tentative de couplage coopératif avec un nœud du même sous-réseau
506
+ // Attempt cooperative coupling with a peer node in the same subnet
412
507
  $peer = self::findPeerInSubnet($clientIp, $nonce);
413
508
  if ($peer !== null) {
414
509
  $challenge['peerId'] = $peer['nodeId'];
415
510
  $challenge['peerBlockIdx'] = random_int(0, $maxBlocks - 1);
416
-
511
+
417
512
  $store = StoreManager::getStore();
418
513
  $store->set("coop-assoc:{$nonce}", [
419
514
  'peerNodeId' => $peer['nodeId'],
@@ -425,14 +520,26 @@ class ChallengeUtils
425
520
  return $challenge;
426
521
  }
427
522
 
523
+ /**
524
+ * Verifies a proof-of-space solution by reconstructing the expected hash
525
+ * from the queried blocks. If a cooperative association exists for the nonce,
526
+ * the peer block is also appended to the combined data.
527
+ *
528
+ * @param string $nonce The challenge nonce.
529
+ * @param string $solution The client-provided solution hash.
530
+ * @param array $queries The list of queried block indices.
531
+ * @param string $seed The seed used to generate the blocks.
532
+ * @param string $clientSecret The client secret.
533
+ * @return bool True if the solution matches, false otherwise.
534
+ */
428
535
  public static function verifySpacePoW(string $nonce, string $solution, array $queries, string $seed, string $clientSecret): bool
429
536
  {
430
537
  $combined = '';
431
538
  foreach ($queries as $idx) {
432
539
  $combined .= self::generateBlock($seed, (int)$idx);
433
540
  }
434
-
435
- // Vérification de la preuve coopérative
541
+
542
+ // Cooperative proof verification
436
543
  $store = StoreManager::getStore();
437
544
  $assoc = $store->get("coop-assoc:{$nonce}");
438
545
  if ($assoc !== null) {
@@ -449,6 +556,15 @@ class ChallengeUtils
449
556
  return hash_equals($hash, $solution);
450
557
  }
451
558
 
559
+ /**
560
+ * Verifies a Schnorr-style Zero-Knowledge Proof using the secp256k1 prime.
561
+ * Checks that g^s ≡ t * y^c (mod p), where c = SHA-256(g, y, t) mod p.
562
+ *
563
+ * @param string $yStr The public key y (hex).
564
+ * @param string $tStr The commitment t (hex).
565
+ * @param string $sStr The response s (hex).
566
+ * @return bool True if the proof is valid, false otherwise.
567
+ */
452
568
  public static function verifyZkpProof(string $yStr, string $tStr, string $sStr): bool
453
569
  {
454
570
  try {
@@ -474,7 +590,11 @@ class ChallengeUtils
474
590
  }
475
591
 
476
592
  /**
477
- * Récupère la clé secrète pour les PoW depuis les variables d'environnement.
593
+ * Retrieves the secret key for PoW tasks from environment variables.
594
+ * Throws an exception in production if the secret is missing.
595
+ *
596
+ * @return string The PoW secret.
597
+ * @throws \RuntimeException If missing in production.
478
598
  */
479
599
  public static function getPowSecret(): string
480
600
  {
@@ -487,9 +607,70 @@ class ChallengeUtils
487
607
  }
488
608
 
489
609
  /**
490
- * Génère un ticket stateless chiffré et signé contenant le contexte d'autorisation.
491
- * @param array $payload
492
- * @return string
610
+ * Programmatically generates an Ed25519 key pair in PEM format
611
+ * matching OpenSSL CLI:
612
+ * `openssl genpkey -algorithm ed25519 -out issuer-private.pem`
613
+ * `openssl pkey -in issuer-private.pem -pubout -out issuer-public.pem`
614
+ *
615
+ * @param string $outDir Target directory to store the PEM files.
616
+ * @param array $options Options including custom file names.
617
+ * @return array{privateKeyPath: string, publicKeyPath: string, privateKey: string, publicKey: string}
618
+ * @throws \RuntimeException If OpenSSL or Ed25519 is not supported or key generation fails.
619
+ */
620
+ public static function generateIssuerPemKeys(string $outDir = '', array $options = []): array
621
+ {
622
+ if (!defined('OPENSSL_KEYTYPE_ED25519')) {
623
+ throw new \RuntimeException("Ed25519 is not supported in this OpenSSL environment.");
624
+ }
625
+
626
+ $pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
627
+ if (!$pkey || !@openssl_pkey_export($pkey, $privateKeyPem)) {
628
+ throw new \RuntimeException("Failed to generate Ed25519 private key.");
629
+ }
630
+
631
+ $details = openssl_pkey_get_details($pkey);
632
+ $publicKeyPem = $details['key'] ?? '';
633
+ if (empty($publicKeyPem)) {
634
+ throw new \RuntimeException("Failed to extract Ed25519 public key.");
635
+ }
636
+
637
+ $privName = $options['privateKeyName'] ?? 'issuer-private.pem';
638
+ $pubName = $options['publicKeyName'] ?? 'issuer-public.pem';
639
+
640
+ $privateKeyPath = '';
641
+ $publicKeyPath = '';
642
+
643
+ if ($outDir !== '') {
644
+ if (!is_dir($outDir)) {
645
+ if (function_exists('wp_mkdir_p')) {
646
+ wp_mkdir_p($outDir);
647
+ } else {
648
+ mkdir($outDir, 0755, true);
649
+ }
650
+ }
651
+ $privateKeyPath = rtrim($outDir, '/\\') . '/' . $privName;
652
+ $publicKeyPath = rtrim($outDir, '/\\') . '/' . $pubName;
653
+ file_put_contents($privateKeyPath, $privateKeyPem);
654
+ @chmod($privateKeyPath, 0600);
655
+ file_put_contents($publicKeyPath, $publicKeyPem);
656
+ @chmod($publicKeyPath, 0644);
657
+ }
658
+
659
+ return [
660
+ 'privateKeyPath' => $privateKeyPath,
661
+ 'publicKeyPath' => $publicKeyPath,
662
+ 'privateKey' => $privateKeyPem,
663
+ 'publicKey' => $publicKeyPem,
664
+ ];
665
+ }
666
+
667
+ /**
668
+ * Generates an encrypted and signed stateless ticket containing the
669
+ * authorization context. Uses Ed25519 if a private key is available,
670
+ * otherwise falls back to AES-256-CBC + HMAC-SHA256.
671
+ *
672
+ * @param array $payload The authorization context to embed.
673
+ * @return string The encoded ticket.
493
674
  */
494
675
  public static function generateStatelessTicket(array $payload): string
495
676
  {
@@ -513,17 +694,19 @@ class ChallengeUtils
513
694
  $iv = random_bytes(16);
514
695
  $encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
515
696
  $signature = hash_hmac('sha256', $iv . $encrypted, $key, true);
516
-
697
+
517
698
  return rtrim(strtr(base64_encode($iv), '+/', '-_'), '=') . '.' .
518
- rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
519
- rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
699
+ rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
700
+ rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
520
701
  }
521
702
 
522
703
  /**
523
- * Décode et valide un ticket stateless chiffré et signé.
524
- * @param string $ticket
525
- * @param string $secret
526
- * @return array|null
704
+ * Decodes and validates an encrypted and signed stateless ticket.
705
+ * Supports both Ed25519 and AES-256-CBC + HMAC-SHA256 formats.
706
+ *
707
+ * @param string $ticket The ticket to decode.
708
+ * @param string $secret Optional secret for HMAC verification.
709
+ * @return array|null The decoded payload, or null if invalid.
527
710
  */
528
711
  public static function parseStatelessTicket(string $ticket, string $secret = ''): ?array
529
712
  {
@@ -538,13 +721,13 @@ class ChallengeUtils
538
721
  };
539
722
  $payloadJson = $base64UrlDecode($parts[1]);
540
723
  $signature = $base64UrlDecode($parts[2]);
541
-
724
+
542
725
  $ed25519PubKey = Env::get('ED25519_PUBLIC_KEY');
543
726
  if (!$ed25519PubKey) {
544
727
  self::logError("[ChallengeUtils] ED25519_PUBLIC_KEY is not defined in environment.");
545
728
  return null;
546
729
  }
547
-
730
+
548
731
  $publicKey = openssl_pkey_get_public($ed25519PubKey);
549
732
  if ($publicKey && openssl_verify($payloadJson, $signature, $publicKey, null) === 1) {
550
733
  return json_decode($payloadJson, true);
@@ -579,8 +762,18 @@ class ChallengeUtils
579
762
  }
580
763
 
581
764
  /**
582
- * Vérifie si un ticket de passage est valide (supporte les tickets opaques via store et le fallback legacy).
583
- * Supporte une clé secrète optionnelle passée en paramètre pour la compatibilité avec les tests.
765
+ * Checks whether a pass ticket is valid. Supports opaque tickets (via the store)
766
+ * and the legacy stateless fallback. Handles greenlist, ZKP, same-IP, same-subnet,
767
+ * and cross-network roaming validations.
768
+ *
769
+ * @param string|null $ip The client IP.
770
+ * @param string|null $ticket The ticket to validate.
771
+ * @param string $deviceId The client device ID.
772
+ * @param string $deviceHash The client device hash.
773
+ * @param bool $allowCrossNetworkRoaming Whether to allow roaming across networks.
774
+ * @param string $secret Optional secret for legacy ticket validation.
775
+ * @param string $zkpProof Optional ZKP proof in the format "y:t:s".
776
+ * @return bool True if the ticket is valid, false otherwise.
584
777
  */
585
778
  public static function isTicketValid(
586
779
  ?string $ip,
@@ -595,7 +788,7 @@ class ChallengeUtils
595
788
  return false;
596
789
  }
597
790
 
598
- // Tentative de validation stateless d'abord
791
+ // Try stateless validation first
599
792
  $ticketData = self::parseStatelessTicket($ticket, $secret);
600
793
  if ($ticketData !== null) {
601
794
  $expiry = $ticketData['expiry'] ?? null;
@@ -611,18 +804,18 @@ class ChallengeUtils
611
804
  return true;
612
805
  }
613
806
  }
614
- if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
615
- $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
616
- if (!empty($zkpProof)) {
617
- $zkpParts = explode(':', $zkpProof);
618
- if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
619
- if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
620
- return true;
621
- }
807
+ if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
808
+ $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
809
+ if (!empty($zkpProof)) {
810
+ $zkpParts = explode(':', $zkpProof);
811
+ if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
812
+ if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
813
+ return true;
622
814
  }
623
815
  }
624
- return false;
625
816
  }
817
+ return false;
818
+ }
626
819
  if ($ip === $originalIp) {
627
820
  return true;
628
821
  }
@@ -655,18 +848,18 @@ class ChallengeUtils
655
848
  return true;
656
849
  }
657
850
  }
658
- if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
659
- $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
660
- if (!empty($zkpProof)) {
661
- $zkpParts = explode(':', $zkpProof);
662
- if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
663
- if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
664
- return true;
665
- }
851
+ if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
852
+ $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
853
+ if (!empty($zkpProof)) {
854
+ $zkpParts = explode(':', $zkpProof);
855
+ if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
856
+ if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
857
+ return true;
666
858
  }
667
859
  }
668
- return false;
669
860
  }
861
+ return false;
862
+ }
670
863
 
671
864
  if ($ip === $originalIp) {
672
865
  return true;
@@ -685,7 +878,7 @@ class ChallengeUtils
685
878
  return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
686
879
  }
687
880
 
688
- // Fallback rétrocompatible pour les anciens tickets signés (sans état)
881
+ // Backward-compatible fallback for legacy signed tickets (stateless)
689
882
  if (!str_contains($ticket, ':')) {
690
883
  return false;
691
884
  }
@@ -701,7 +894,12 @@ class ChallengeUtils
701
894
  }
702
895
 
703
896
  /**
704
- * Calcule la cible de difficulté pour un challenge CPU en fonction du facteur de suspicion.
897
+ * Computes the difficulty target for a CPU challenge based on the
898
+ * suspicion factor. Higher suspicion yields a harder (smaller) target.
899
+ *
900
+ * @param float $suspicionFactor The suspicion factor (0.0 to 1.0).
901
+ * @param array $securityConfig The security configuration.
902
+ * @return string The target as a hex string.
705
903
  */
706
904
  public static function calculateCpuTarget(float $suspicionFactor, array $securityConfig): string
707
905
  {
@@ -712,7 +910,7 @@ class ChallengeUtils
712
910
  $totalDifficultyBits = $minDifficultyBits + $suspicionFactor * ($maxDifficultyBits - $minDifficultyBits);
713
911
 
714
912
  if ($totalDifficultyBits <= 0) {
715
- // Cible maximale (challenge trivial)
913
+ // Maximum target (trivial challenge)
716
914
  return (BigInt::pow(2, 256)->sub(new BigInt(1)))->toHex();
717
915
  }
718
916
 
@@ -721,7 +919,15 @@ class ChallengeUtils
721
919
  }
722
920
 
723
921
  /**
724
- * Crée le bloc de données de base pour le challenge CPU.
922
+ * Creates the base data block used by the CPU challenge. The fingerprint
923
+ * parts are sorted to ensure deterministic ordering.
924
+ *
925
+ * @param string $nonce The challenge nonce.
926
+ * @param string $clientSecret The client secret.
927
+ * @param string $fingerprint The client fingerprint (pipe-separated).
928
+ * @param string $clientIp The client IP.
929
+ * @param string $tlsSessionId The TLS session ID.
930
+ * @return string The concatenated base block.
725
931
  */
726
932
  public static function createCpuChallengeBaseBlock(string $nonce, string $clientSecret, string $fingerprint, string $clientIp = '', string $tlsSessionId = ''): string
727
933
  {
@@ -729,13 +935,23 @@ class ChallengeUtils
729
935
  $filteredParts = array_filter($parts);
730
936
  sort($filteredParts);
731
937
  $sortedFingerprint = implode('|', $filteredParts);
732
-
938
+
733
939
  return "{$nonce}:{$clientSecret}:{$sortedFingerprint}:{$clientIp}:{$tlsSessionId}:";
734
940
  }
735
941
 
736
942
  /**
737
- * Vérifie une solution de PoW CPU et génère un ticket si elle est valide.
738
- * @return string|null Le ticket opaque en cas de succès, sinon null.
943
+ * Verifies a CPU Proof-of-Work solution and, on success, generates a
944
+ * stateless ticket. In HTTP (insecure) mode, no SHA-256 computation is
945
+ * required and a ticket is issued directly.
946
+ *
947
+ * @param string $clientIp The client IP.
948
+ * @param int $ticketTtl The ticket time-to-live in milliseconds.
949
+ * @param string $nonce The challenge nonce.
950
+ * @param string $solution The client-provided solution.
951
+ * @param array $challengeContext The challenge context (cpuTarget, baseBlock, isHttp).
952
+ * @param string $deviceId The client device ID.
953
+ * @param string $deviceHash The client device hash.
954
+ * @return string|null The opaque ticket on success, or null on failure.
739
955
  */
740
956
  public static function verifyCpuTargetPoWAndGenerateTicket(
741
957
  string $clientIp,
@@ -746,9 +962,9 @@ class ChallengeUtils
746
962
  string $deviceId = '',
747
963
  string $deviceHash = ''
748
964
  ): ?string {
749
- // En mode HTTP (non sécurisé), aucun calcul SHA-256 n'est exigé
965
+ // In HTTP (insecure) mode, no SHA-256 computation is required
750
966
  if (!empty($challengeContext['isHttp'])) {
751
- self::logError('[FP Server Verify] Mode HTTP détecté (insecure policy) : validation sans SHA-256 acceptée.');
967
+ self::logError('[FP Server Verify] HTTP mode detected (insecure policy): validation without SHA-256 accepted.');
752
968
  $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
753
969
  return self::generateStatelessTicket([
754
970
  'expiry' => $expiry,
@@ -775,7 +991,7 @@ class ChallengeUtils
775
991
 
776
992
  if ($isValid) {
777
993
  self::logError('[FP Server Verify] CPU PoW verification PASSED.');
778
-
994
+
779
995
  $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
780
996
  $payload = [
781
997
  'expiry' => $expiry,
@@ -797,7 +1013,13 @@ class ChallengeUtils
797
1013
  }
798
1014
 
799
1015
  /**
800
- * Vérifie le limiteur de débit Token Bucket pour les demandes de challenge d'un sous-réseau.
1016
+ * Checks the Token Bucket rate limiter for challenge requests from a subnet.
1017
+ * Tokens are refilled at a constant rate up to a maximum capacity.
1018
+ *
1019
+ * @param string $clientIp The client IP (subnet is used as the rate-limit key).
1020
+ * @param float $capacity The bucket capacity (max tokens).
1021
+ * @param float $refillRate The token refill rate (tokens per second).
1022
+ * @return bool True if the request is allowed, false if rate-limited.
801
1023
  */
802
1024
  public static function checkChallengeRateLimit(string $clientIp, float $capacity = 5.0, float $refillRate = 0.1): bool
803
1025
  {
@@ -836,7 +1058,14 @@ class ChallengeUtils
836
1058
  }
837
1059
 
838
1060
  /**
839
- * Vérifie une solution de PoW mémoire.
1061
+ * Derives the set of challenged block indices for a memory PoW solution,
1062
+ * based on the seed, the solution, and the number of blocks.
1063
+ *
1064
+ * @param string $seed The challenge seed.
1065
+ * @param int $solution The client solution (used as a salt in the hash).
1066
+ * @param int $numBlocks The total number of blocks.
1067
+ * @param int $k The number of challenged indices to derive (default 4).
1068
+ * @return array<int> The list of challenged block indices.
840
1069
  */
841
1070
  private static function getChallengedIndices(string $seed, int $solution, int $numBlocks, int $k = 4): array
842
1071
  {
@@ -849,6 +1078,16 @@ class ChallengeUtils
849
1078
  return $indices;
850
1079
  }
851
1080
 
1081
+ /**
1082
+ * Verifies a Merkle proof for a given leaf hash, index, and expected root.
1083
+ * Recomputes the root by hashing pairs of siblings along the path.
1084
+ *
1085
+ * @param string $leafHash The hex hash of the leaf.
1086
+ * @param int $index The leaf index in the tree.
1087
+ * @param array $proof The list of sibling hashes (hex).
1088
+ * @param string $root The expected Merkle root (hex).
1089
+ * @return bool True if the proof is valid, false otherwise.
1090
+ */
852
1091
  private static function verifyMerkleProof(string $leafHash, int $index, array $proof, string $root): bool
853
1092
  {
854
1093
  $currentHash = $leafHash;
@@ -861,6 +1100,17 @@ class ChallengeUtils
861
1100
  return $currentHash === $root;
862
1101
  }
863
1102
 
1103
+ /**
1104
+ * Legacy memory PoW verification, used for low-difficulty challenges with
1105
+ * a simple numeric solution. Rebuilds the whole buffer and replays the
1106
+ * random walk to compare with the provided solution.
1107
+ *
1108
+ * @param string $nonce The challenge nonce.
1109
+ * @param int $solution The client-provided solution.
1110
+ * @param int $difficulty The difficulty (in MB).
1111
+ * @param string $clientSecret The client secret.
1112
+ * @return bool True if the solution matches, false otherwise.
1113
+ */
864
1114
  private static function verifyMemoryPoWLegacy(string $nonce, int $solution, int $difficulty, string $clientSecret): bool
865
1115
  {
866
1116
  $size = $difficulty * 1024 * 1024;
@@ -887,6 +1137,17 @@ class ChallengeUtils
887
1137
  return $finalHash === $solution;
888
1138
  }
889
1139
 
1140
+ /**
1141
+ * Verifies a memory PoW solution. Supports both the modern JSON format
1142
+ * (with Merkle proofs) and the legacy numeric format for low difficulties.
1143
+ * Enforces a maximum allowed difficulty to prevent DoS.
1144
+ *
1145
+ * @param string $nonce The challenge nonce.
1146
+ * @param string $solution The client-provided solution (JSON or numeric string).
1147
+ * @param int $difficulty The difficulty (in MB). 0 means the challenge is skipped.
1148
+ * @param string $clientSecret The client secret.
1149
+ * @return bool True if the solution is valid, false otherwise.
1150
+ */
890
1151
  public static function verifyMemoryPoW(
891
1152
  string $nonce,
892
1153
  string $solution,
@@ -972,7 +1233,12 @@ class ChallengeUtils
972
1233
  }
973
1234
 
974
1235
  /**
975
- * Émule la multiplication 32-bit `Math.imul` de JavaScript.
1236
+ * Emulates JavaScript's 32-bit signed integer multiplication (Math.imul),
1237
+ * handling overflow correctly.
1238
+ *
1239
+ * @param int $a The first operand.
1240
+ * @param int $b The second operand.
1241
+ * @return int The 32-bit signed result of a * b.
976
1242
  */
977
1243
  private static function gmp_imul(int $a, int $b): int
978
1244
  {
@@ -984,9 +1250,11 @@ class ChallengeUtils
984
1250
  }
985
1251
 
986
1252
  /**
987
- * Génère une URL piège signée.
988
- * @param string $nonce Le nonce pour signer l'URL.
989
- * @return string L'URL piège.
1253
+ * Generates a signed trap URL. The URL is picked from a template and a
1254
+ * signature is appended as a query parameter.
1255
+ *
1256
+ * @param string $nonce The nonce used to sign the URL.
1257
+ * @return string The trap URL.
990
1258
  */
991
1259
  public static function generateTrapUrl(string $nonce): string
992
1260
  {
@@ -999,11 +1267,13 @@ class ChallengeUtils
999
1267
  }
1000
1268
 
1001
1269
  /**
1002
- * Vérifie si une URL donnée est une URL piège valide pour un nonce donné.
1003
- * @param string $path Le chemin de la requête.
1004
- * @param string $signature La signature provenant de la query string.
1005
- * @param string $nonce Le nonce à vérifier.
1006
- * @return bool
1270
+ * Verifies whether a given path and signature form a valid trap URL for
1271
+ * the given nonce. Uses hash_equals for timing-safe comparison.
1272
+ *
1273
+ * @param string $path The request path.
1274
+ * @param string $signature The signature from the query string.
1275
+ * @param string $nonce The nonce to verify against.
1276
+ * @return bool True if the trap URL is valid, false otherwise.
1007
1277
  */
1008
1278
  public static function verifyTrapUrl(string $path, string $signature, string $nonce): bool
1009
1279
  {
@@ -1011,39 +1281,56 @@ class ChallengeUtils
1011
1281
  return false;
1012
1282
  }
1013
1283
  $expectedSignature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
1014
- // Utilise hash_equals pour une comparaison sécurisée contre les attaques temporelles.
1284
+ // Use hash_equals for timing-safe comparison.
1015
1285
  return hash_equals($expectedSignature, $signature);
1016
1286
  }
1017
1287
 
1018
1288
  /**
1019
- * Charge le contenu du solveur JS pour l'injection inline.
1020
- * @return string Le code JavaScript du solveur.
1289
+ * Loads the JavaScript solver code for inline injection into challenge pages.
1290
+ * Searches several candidate paths, including WordPress plugin directories.
1291
+ *
1292
+ * @return string The JavaScript solver code, or an empty string if not found.
1021
1293
  */
1022
1294
  private static function getPowSolverCode(): string
1023
1295
  {
1296
+ if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
1297
+ $wpPluginSolver = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/assets/pow.solver.inline.js';
1298
+ if (file_exists($wpPluginSolver)) {
1299
+ return (string)file_get_contents($wpPluginSolver);
1300
+ }
1301
+ } elseif (defined('WP_PLUGIN_DIR')) {
1302
+ $wpPluginSolver = WP_PLUGIN_DIR . '/anonympins-bot-mitigation-pow/assets/pow.solver.inline.js';
1303
+ if (file_exists($wpPluginSolver)) {
1304
+ return (string)file_get_contents($wpPluginSolver);
1305
+ }
1306
+ }
1307
+
1024
1308
  $candidates = [
1025
1309
  __DIR__ . '/../../js/pow.solver.inline.js',
1026
1310
  __DIR__ . '/../assets/pow.solver.inline.js',
1027
1311
  __DIR__ . '/../js/pow.solver.inline.js',
1028
- dirname(__DIR__, 2) . '/assets/pow.solver.inline.js',
1029
- dirname(__DIR__, 2) . '/js/pow.solver.inline.js',
1030
- dirname(__DIR__, 1) . '/assets/pow.solver.inline.js',
1312
+ dirname(__DIR__) . '/assets/pow.solver.inline.js',
1313
+ dirname(__DIR__) . '/js/pow.solver.inline.js',
1031
1314
  ];
1032
- if (defined('ABSPATH')) {
1033
- $wpPluginSolver = ABSPATH . 'wp-content/plugins/fingerprint-anti-bot/assets/pow.solver.inline.js';
1034
- if (file_exists($wpPluginSolver)) {
1035
- return file_get_contents($wpPluginSolver) ?: '';
1036
- }
1037
- }
1038
1315
  foreach ($candidates as $solverPath) {
1039
1316
  if (file_exists($solverPath)) {
1040
1317
  return file_get_contents($solverPath) ?: '';
1041
1318
  }
1042
1319
  }
1043
- self::logError("[ChallengeUtils] Erreur: Le fichier pow.solver.inline.js n'a pas été trouvé à l'emplacement attendu.");
1320
+ self::logError("[ChallengeUtils] Error: The pow.solver.inline.js file was not found at the expected location.");
1044
1321
  return '';
1045
1322
  }
1046
1323
 
1324
+ /**
1325
+ * Generates the HTML page for a proof-of-space challenge, embedding the
1326
+ * solver code and the challenge script. The page initializes local storage,
1327
+ * runs the proof-of-space solver, and redirects with the solution.
1328
+ *
1329
+ * @param array $challengeDetails The challenge details (nonce, sizeMb, queries, path, peerId, peerBlockIdx).
1330
+ * @param string $clientSecret The client secret.
1331
+ * @param array $securityConfig The security configuration.
1332
+ * @return string The generated HTML page.
1333
+ */
1047
1334
  public static function generateSpaceChallengePage(array $challengeDetails, string $clientSecret, array $securityConfig): string
1048
1335
  {
1049
1336
  $nonce = $challengeDetails['nonce'];
@@ -1089,14 +1376,21 @@ class ChallengeUtils
1089
1376
  }
1090
1377
 
1091
1378
  /**
1092
- * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
1093
- * @param array $cpuChallengeDetails
1094
- * @param int $memoryDifficulty
1095
- * @param string $clientSecret
1096
- * @param array $securityConfig
1097
- * @param array $trapUrls
1098
- * @param string $originalFingerprint
1099
- * @return string
1379
+ * Generates the HTML page for a combined CPU + Memory proof-of-work challenge.
1380
+ * The page embeds the solver code, the challenge script, and hidden trap links
1381
+ * (rendered in random tags/positions) to lure malicious crawlers.
1382
+ *
1383
+ * @param array $cpuChallengeDetails The CPU challenge details (nonce, target, path).
1384
+ * @param int $memoryDifficulty The memory difficulty in MB (0 to skip).
1385
+ * @param string $clientSecret The client secret.
1386
+ * @param array $securityConfig The security configuration.
1387
+ * @param array $trapUrls The list of trap URLs to embed.
1388
+ * @param string $originalFingerprint The original client fingerprint.
1389
+ * @param string $clientIp The client IP.
1390
+ * @param string $tlsSessionId The TLS session ID.
1391
+ * @param string|null $baseBlock Optional pre-computed base block.
1392
+ * @param bool $isHttps Whether the request is over HTTPS.
1393
+ * @return string The generated HTML page.
1100
1394
  */
1101
1395
  public static function generateCombinedPoWChallengePage(
1102
1396
  array $cpuChallengeDetails,
@@ -1191,12 +1485,39 @@ class ChallengeUtils
1191
1485
  );
1192
1486
  }
1193
1487
 
1488
+ /**
1489
+ * Logs an error message using the PHP error log.
1490
+ *
1491
+ * @param string $message The message to log.
1492
+ * @return void
1493
+ */
1194
1494
  private static function logError(string $message): void
1195
1495
  {
1196
1496
  // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Challenge diagnostic logging
1197
1497
  error_log($message);
1198
1498
  }
1199
1499
 
1500
+ /**
1501
+ * Safely sanitizes a string input using WordPress functions if available, or fallback PHP functions.
1502
+ *
1503
+ * @param mixed $value
1504
+ * @return string
1505
+ */
1506
+ private static function sanitizeString($value): string
1507
+ {
1508
+ if (!is_scalar($value)) {
1509
+ return '';
1510
+ }
1511
+ $val = (string)$value;
1512
+ if (function_exists('wp_unslash')) {
1513
+ $val = wp_unslash($val);
1514
+ }
1515
+ if (function_exists('sanitize_text_field')) {
1516
+ return sanitize_text_field($val);
1517
+ }
1518
+ return trim(strip_tags($val));
1519
+ }
1520
+
1200
1521
  /**
1201
1522
  * Safely retrieves and sanitizes a value from the $_SERVER superglobal.
1202
1523
  * Uses WordPress functions if available, otherwise falls back to basic PHP sanitization.
@@ -1213,14 +1534,6 @@ class ChallengeUtils
1213
1534
 
1214
1535
  // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed and sanitized immediately below
1215
1536
  $value = $_SERVER[$key];
1216
-
1217
- if (function_exists('wp_unslash')) {
1218
- $value = wp_unslash($value);
1219
- }
1220
- if (function_exists('sanitize_text_field')) {
1221
- return sanitize_text_field($value);
1222
- }
1223
-
1224
- return is_scalar($value) ? (string) $value : '';
1537
+ return self::sanitizeString($value);
1225
1538
  }
1226
1539
  }