@anonympins/fingerprint 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/public/anonympins-bot-mitigation-pow.zip +0 -0
  5. package/src/js/dynamic-wasm.js +218 -218
  6. package/src/js/fingerprint.client.js +1784 -1738
  7. package/src/js/fingerprint.client.obfuscated.js +1 -1
  8. package/src/js/fingerprint.js +489 -99
  9. package/src/js/fingerprint.utils.js +252 -2
  10. package/src/js/pow.worker.js +2 -2
  11. package/src/js/tests/cross_parity.test.js +3 -3
  12. package/src/js/tests/dns-circuit-breaker.test.js +6 -6
  13. package/src/js/tests/dynamic-wasm.test.js +77 -77
  14. package/src/js/tests/fingerprint.builder.test.js +76 -78
  15. package/src/js/tests/fingerprint.client.init.test.js +78 -0
  16. package/src/js/tests/fingerprint.client.test.js +16 -17
  17. package/src/js/tests/fingerprint.test.js +37 -12
  18. package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
  19. package/src/js/tests/library.test.js +94 -95
  20. package/src/js/tests/obfuscation.test.js +8 -8
  21. package/src/js/tests/quicFingerprint.test.js +47 -0
  22. package/src/js/tests/tcpFingerprint.test.js +4 -4
  23. package/src/js/tests/threatIntel.test.js +40 -1
  24. package/src/js/upow-model-task.js +155 -112
  25. package/src/php/Challenge/ChallengeUtils.php +392 -86
  26. package/src/php/Config/SecurityProfiles.php +79 -61
  27. package/src/php/DirectFingerprint.php +55 -8
  28. package/src/php/FingerprintBuilder.php +13 -13
  29. package/src/php/FingerprintClient.php +6 -6
  30. package/src/php/FingerprintEngine.php +2122 -1690
  31. package/src/php/Ja3AnomalyDetector.php +22 -22
  32. package/src/php/Optimization/FunctionRegistry.php +4 -4
  33. package/src/php/Optimization/Optimization.php +6 -6
  34. package/src/php/RequestContext.php +11 -11
  35. package/src/php/Store/InMemoryStore.php +96 -66
  36. package/src/php/Store/MongoDbStore.php +2 -2
  37. package/src/php/Store/RedisStore.php +1 -1
  38. package/src/php/Store/StoreManager.php +48 -35
  39. package/src/php/Tests/ChallengeUtilsTest.php +31 -0
  40. package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
  41. package/src/php/Tests/FingerprintClientTest.php +70 -70
  42. package/src/php/Tests/PowTest.php +37 -39
  43. package/src/php/Tests/QuicFingerprintTest.php +115 -53
  44. package/src/php/Tests/ThreatIntelTest.php +34 -0
  45. package/src/php/Utils/BigInt.php +202 -202
  46. package/src/php/Utils/BlockList.php +13 -13
  47. package/src/php/Utils/Logger.php +19 -1
  48. package/src/php/Utils/MaliciousPatterns.php +6 -6
  49. package/src/php/Utils/MetricsManager.php +209 -209
  50. package/src/php/Utils/RequestUtils.php +362 -1
  51. package/src/php/Utils/TLSClientHelloParser.php +385 -369
  52. package/src/php/WordPress/WpDbStore.php +7 -7
  53. package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1121 -0
  54. package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
  55. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
  56. package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -0
  57. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
  58. package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +297 -261
  59. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
  60. package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
  61. package/src/php/WordPress/package.php +80 -101
  62. package/src/php/WordPress/readme.txt +21 -5
  63. package/src/php/bin/auto-tune.php +1 -1
  64. 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'];
@@ -295,7 +378,7 @@ 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':
@@ -343,7 +426,7 @@ class ChallengeUtils
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[] = [
@@ -367,7 +450,7 @@ class ChallengeUtils
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'];
@@ -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,71 @@ 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
+ if ($outDir === '') {
638
+ $outDir = dirname(__DIR__, 2) . '/config';
639
+ }
640
+
641
+ if (!is_dir($outDir)) {
642
+ if (function_exists('wp_mkdir_p')) {
643
+ wp_mkdir_p($outDir);
644
+ } else {
645
+ mkdir($outDir, 0755, true);
646
+ }
647
+ }
648
+
649
+ $privName = $options['privateKeyName'] ?? 'issuer-private.pem';
650
+ $pubName = $options['publicKeyName'] ?? 'issuer-public.pem';
651
+
652
+ $privateKeyPath = rtrim($outDir, '/\\') . '/' . $privName;
653
+ $publicKeyPath = rtrim($outDir, '/\\') . '/' . $pubName;
654
+
655
+ file_put_contents($privateKeyPath, $privateKeyPem);
656
+ @chmod($privateKeyPath, 0600);
657
+ file_put_contents($publicKeyPath, $publicKeyPem);
658
+ @chmod($publicKeyPath, 0644);
659
+
660
+ return [
661
+ 'privateKeyPath' => $privateKeyPath,
662
+ 'publicKeyPath' => $publicKeyPath,
663
+ 'privateKey' => $privateKeyPem,
664
+ 'publicKey' => $publicKeyPem,
665
+ ];
666
+ }
667
+
668
+ /**
669
+ * Generates an encrypted and signed stateless ticket containing the
670
+ * authorization context. Uses Ed25519 if a private key is available,
671
+ * otherwise falls back to AES-256-CBC + HMAC-SHA256.
672
+ *
673
+ * @param array $payload The authorization context to embed.
674
+ * @return string The encoded ticket.
493
675
  */
494
676
  public static function generateStatelessTicket(array $payload): string
495
677
  {
@@ -513,17 +695,19 @@ class ChallengeUtils
513
695
  $iv = random_bytes(16);
514
696
  $encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
515
697
  $signature = hash_hmac('sha256', $iv . $encrypted, $key, true);
516
-
698
+
517
699
  return rtrim(strtr(base64_encode($iv), '+/', '-_'), '=') . '.' .
518
- rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
519
- rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
700
+ rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
701
+ rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
520
702
  }
521
703
 
522
704
  /**
523
- * Décode et valide un ticket stateless chiffré et signé.
524
- * @param string $ticket
525
- * @param string $secret
526
- * @return array|null
705
+ * Decodes and validates an encrypted and signed stateless ticket.
706
+ * Supports both Ed25519 and AES-256-CBC + HMAC-SHA256 formats.
707
+ *
708
+ * @param string $ticket The ticket to decode.
709
+ * @param string $secret Optional secret for HMAC verification.
710
+ * @return array|null The decoded payload, or null if invalid.
527
711
  */
528
712
  public static function parseStatelessTicket(string $ticket, string $secret = ''): ?array
529
713
  {
@@ -538,13 +722,13 @@ class ChallengeUtils
538
722
  };
539
723
  $payloadJson = $base64UrlDecode($parts[1]);
540
724
  $signature = $base64UrlDecode($parts[2]);
541
-
725
+
542
726
  $ed25519PubKey = Env::get('ED25519_PUBLIC_KEY');
543
727
  if (!$ed25519PubKey) {
544
728
  self::logError("[ChallengeUtils] ED25519_PUBLIC_KEY is not defined in environment.");
545
729
  return null;
546
730
  }
547
-
731
+
548
732
  $publicKey = openssl_pkey_get_public($ed25519PubKey);
549
733
  if ($publicKey && openssl_verify($payloadJson, $signature, $publicKey, null) === 1) {
550
734
  return json_decode($payloadJson, true);
@@ -579,8 +763,18 @@ class ChallengeUtils
579
763
  }
580
764
 
581
765
  /**
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.
766
+ * Checks whether a pass ticket is valid. Supports opaque tickets (via the store)
767
+ * and the legacy stateless fallback. Handles greenlist, ZKP, same-IP, same-subnet,
768
+ * and cross-network roaming validations.
769
+ *
770
+ * @param string|null $ip The client IP.
771
+ * @param string|null $ticket The ticket to validate.
772
+ * @param string $deviceId The client device ID.
773
+ * @param string $deviceHash The client device hash.
774
+ * @param bool $allowCrossNetworkRoaming Whether to allow roaming across networks.
775
+ * @param string $secret Optional secret for legacy ticket validation.
776
+ * @param string $zkpProof Optional ZKP proof in the format "y:t:s".
777
+ * @return bool True if the ticket is valid, false otherwise.
584
778
  */
585
779
  public static function isTicketValid(
586
780
  ?string $ip,
@@ -595,7 +789,7 @@ class ChallengeUtils
595
789
  return false;
596
790
  }
597
791
 
598
- // Tentative de validation stateless d'abord
792
+ // Try stateless validation first
599
793
  $ticketData = self::parseStatelessTicket($ticket, $secret);
600
794
  if ($ticketData !== null) {
601
795
  $expiry = $ticketData['expiry'] ?? null;
@@ -606,18 +800,23 @@ class ChallengeUtils
606
800
  if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
607
801
  return false;
608
802
  }
609
- if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
610
- $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
611
- if (!empty($zkpProof)) {
612
- $zkpParts = explode(':', $zkpProof);
613
- if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
614
- if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
615
- return true;
616
- }
803
+ if (!empty($ticketData['greenlist']) || str_starts_with((string)$storedDeviceHash, 'webauthn:greenlist:')) {
804
+ if (empty($deviceId) || $deviceId === $storedDeviceId) {
805
+ return true;
806
+ }
807
+ }
808
+ if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
809
+ $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
810
+ if (!empty($zkpProof)) {
811
+ $zkpParts = explode(':', $zkpProof);
812
+ if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
813
+ if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
814
+ return true;
617
815
  }
618
816
  }
619
- return false;
620
817
  }
818
+ return false;
819
+ }
621
820
  if ($ip === $originalIp) {
622
821
  return true;
623
822
  }
@@ -645,18 +844,23 @@ class ChallengeUtils
645
844
  $store->delete("ticket:{$ticket}");
646
845
  return false;
647
846
  }
648
- if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
649
- $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
650
- if (!empty($zkpProof)) {
651
- $zkpParts = explode(':', $zkpProof);
652
- if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
653
- if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
654
- return true;
655
- }
847
+ if (!empty($ticketData['greenlist']) || str_starts_with((string)$storedDeviceHash, 'webauthn:greenlist:')) {
848
+ if (empty($deviceId) || $deviceId === $storedDeviceId) {
849
+ return true;
850
+ }
851
+ }
852
+ if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
853
+ $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
854
+ if (!empty($zkpProof)) {
855
+ $zkpParts = explode(':', $zkpProof);
856
+ if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
857
+ if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
858
+ return true;
656
859
  }
657
860
  }
658
- return false;
659
861
  }
862
+ return false;
863
+ }
660
864
 
661
865
  if ($ip === $originalIp) {
662
866
  return true;
@@ -675,7 +879,7 @@ class ChallengeUtils
675
879
  return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
676
880
  }
677
881
 
678
- // Fallback rétrocompatible pour les anciens tickets signés (sans état)
882
+ // Backward-compatible fallback for legacy signed tickets (stateless)
679
883
  if (!str_contains($ticket, ':')) {
680
884
  return false;
681
885
  }
@@ -691,7 +895,12 @@ class ChallengeUtils
691
895
  }
692
896
 
693
897
  /**
694
- * Calcule la cible de difficulté pour un challenge CPU en fonction du facteur de suspicion.
898
+ * Computes the difficulty target for a CPU challenge based on the
899
+ * suspicion factor. Higher suspicion yields a harder (smaller) target.
900
+ *
901
+ * @param float $suspicionFactor The suspicion factor (0.0 to 1.0).
902
+ * @param array $securityConfig The security configuration.
903
+ * @return string The target as a hex string.
695
904
  */
696
905
  public static function calculateCpuTarget(float $suspicionFactor, array $securityConfig): string
697
906
  {
@@ -702,7 +911,7 @@ class ChallengeUtils
702
911
  $totalDifficultyBits = $minDifficultyBits + $suspicionFactor * ($maxDifficultyBits - $minDifficultyBits);
703
912
 
704
913
  if ($totalDifficultyBits <= 0) {
705
- // Cible maximale (challenge trivial)
914
+ // Maximum target (trivial challenge)
706
915
  return (BigInt::pow(2, 256)->sub(new BigInt(1)))->toHex();
707
916
  }
708
917
 
@@ -711,7 +920,15 @@ class ChallengeUtils
711
920
  }
712
921
 
713
922
  /**
714
- * Crée le bloc de données de base pour le challenge CPU.
923
+ * Creates the base data block used by the CPU challenge. The fingerprint
924
+ * parts are sorted to ensure deterministic ordering.
925
+ *
926
+ * @param string $nonce The challenge nonce.
927
+ * @param string $clientSecret The client secret.
928
+ * @param string $fingerprint The client fingerprint (pipe-separated).
929
+ * @param string $clientIp The client IP.
930
+ * @param string $tlsSessionId The TLS session ID.
931
+ * @return string The concatenated base block.
715
932
  */
716
933
  public static function createCpuChallengeBaseBlock(string $nonce, string $clientSecret, string $fingerprint, string $clientIp = '', string $tlsSessionId = ''): string
717
934
  {
@@ -719,13 +936,23 @@ class ChallengeUtils
719
936
  $filteredParts = array_filter($parts);
720
937
  sort($filteredParts);
721
938
  $sortedFingerprint = implode('|', $filteredParts);
722
-
939
+
723
940
  return "{$nonce}:{$clientSecret}:{$sortedFingerprint}:{$clientIp}:{$tlsSessionId}:";
724
941
  }
725
942
 
726
943
  /**
727
- * Vérifie une solution de PoW CPU et génère un ticket si elle est valide.
728
- * @return string|null Le ticket opaque en cas de succès, sinon null.
944
+ * Verifies a CPU Proof-of-Work solution and, on success, generates a
945
+ * stateless ticket. In HTTP (insecure) mode, no SHA-256 computation is
946
+ * required and a ticket is issued directly.
947
+ *
948
+ * @param string $clientIp The client IP.
949
+ * @param int $ticketTtl The ticket time-to-live in milliseconds.
950
+ * @param string $nonce The challenge nonce.
951
+ * @param string $solution The client-provided solution.
952
+ * @param array $challengeContext The challenge context (cpuTarget, baseBlock, isHttp).
953
+ * @param string $deviceId The client device ID.
954
+ * @param string $deviceHash The client device hash.
955
+ * @return string|null The opaque ticket on success, or null on failure.
729
956
  */
730
957
  public static function verifyCpuTargetPoWAndGenerateTicket(
731
958
  string $clientIp,
@@ -736,9 +963,9 @@ class ChallengeUtils
736
963
  string $deviceId = '',
737
964
  string $deviceHash = ''
738
965
  ): ?string {
739
- // En mode HTTP (non sécurisé), aucun calcul SHA-256 n'est exigé
966
+ // In HTTP (insecure) mode, no SHA-256 computation is required
740
967
  if (!empty($challengeContext['isHttp'])) {
741
- self::logError('[FP Server Verify] Mode HTTP détecté (insecure policy) : validation sans SHA-256 acceptée.');
968
+ self::logError('[FP Server Verify] HTTP mode detected (insecure policy): validation without SHA-256 accepted.');
742
969
  $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
743
970
  return self::generateStatelessTicket([
744
971
  'expiry' => $expiry,
@@ -765,7 +992,7 @@ class ChallengeUtils
765
992
 
766
993
  if ($isValid) {
767
994
  self::logError('[FP Server Verify] CPU PoW verification PASSED.');
768
-
995
+
769
996
  $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
770
997
  $payload = [
771
998
  'expiry' => $expiry,
@@ -787,7 +1014,13 @@ class ChallengeUtils
787
1014
  }
788
1015
 
789
1016
  /**
790
- * Vérifie le limiteur de débit Token Bucket pour les demandes de challenge d'un sous-réseau.
1017
+ * Checks the Token Bucket rate limiter for challenge requests from a subnet.
1018
+ * Tokens are refilled at a constant rate up to a maximum capacity.
1019
+ *
1020
+ * @param string $clientIp The client IP (subnet is used as the rate-limit key).
1021
+ * @param float $capacity The bucket capacity (max tokens).
1022
+ * @param float $refillRate The token refill rate (tokens per second).
1023
+ * @return bool True if the request is allowed, false if rate-limited.
791
1024
  */
792
1025
  public static function checkChallengeRateLimit(string $clientIp, float $capacity = 5.0, float $refillRate = 0.1): bool
793
1026
  {
@@ -826,7 +1059,14 @@ class ChallengeUtils
826
1059
  }
827
1060
 
828
1061
  /**
829
- * Vérifie une solution de PoW mémoire.
1062
+ * Derives the set of challenged block indices for a memory PoW solution,
1063
+ * based on the seed, the solution, and the number of blocks.
1064
+ *
1065
+ * @param string $seed The challenge seed.
1066
+ * @param int $solution The client solution (used as a salt in the hash).
1067
+ * @param int $numBlocks The total number of blocks.
1068
+ * @param int $k The number of challenged indices to derive (default 4).
1069
+ * @return array<int> The list of challenged block indices.
830
1070
  */
831
1071
  private static function getChallengedIndices(string $seed, int $solution, int $numBlocks, int $k = 4): array
832
1072
  {
@@ -839,6 +1079,16 @@ class ChallengeUtils
839
1079
  return $indices;
840
1080
  }
841
1081
 
1082
+ /**
1083
+ * Verifies a Merkle proof for a given leaf hash, index, and expected root.
1084
+ * Recomputes the root by hashing pairs of siblings along the path.
1085
+ *
1086
+ * @param string $leafHash The hex hash of the leaf.
1087
+ * @param int $index The leaf index in the tree.
1088
+ * @param array $proof The list of sibling hashes (hex).
1089
+ * @param string $root The expected Merkle root (hex).
1090
+ * @return bool True if the proof is valid, false otherwise.
1091
+ */
842
1092
  private static function verifyMerkleProof(string $leafHash, int $index, array $proof, string $root): bool
843
1093
  {
844
1094
  $currentHash = $leafHash;
@@ -851,6 +1101,17 @@ class ChallengeUtils
851
1101
  return $currentHash === $root;
852
1102
  }
853
1103
 
1104
+ /**
1105
+ * Legacy memory PoW verification, used for low-difficulty challenges with
1106
+ * a simple numeric solution. Rebuilds the whole buffer and replays the
1107
+ * random walk to compare with the provided solution.
1108
+ *
1109
+ * @param string $nonce The challenge nonce.
1110
+ * @param int $solution The client-provided solution.
1111
+ * @param int $difficulty The difficulty (in MB).
1112
+ * @param string $clientSecret The client secret.
1113
+ * @return bool True if the solution matches, false otherwise.
1114
+ */
854
1115
  private static function verifyMemoryPoWLegacy(string $nonce, int $solution, int $difficulty, string $clientSecret): bool
855
1116
  {
856
1117
  $size = $difficulty * 1024 * 1024;
@@ -877,6 +1138,17 @@ class ChallengeUtils
877
1138
  return $finalHash === $solution;
878
1139
  }
879
1140
 
1141
+ /**
1142
+ * Verifies a memory PoW solution. Supports both the modern JSON format
1143
+ * (with Merkle proofs) and the legacy numeric format for low difficulties.
1144
+ * Enforces a maximum allowed difficulty to prevent DoS.
1145
+ *
1146
+ * @param string $nonce The challenge nonce.
1147
+ * @param string $solution The client-provided solution (JSON or numeric string).
1148
+ * @param int $difficulty The difficulty (in MB). 0 means the challenge is skipped.
1149
+ * @param string $clientSecret The client secret.
1150
+ * @return bool True if the solution is valid, false otherwise.
1151
+ */
880
1152
  public static function verifyMemoryPoW(
881
1153
  string $nonce,
882
1154
  string $solution,
@@ -962,7 +1234,12 @@ class ChallengeUtils
962
1234
  }
963
1235
 
964
1236
  /**
965
- * Émule la multiplication 32-bit `Math.imul` de JavaScript.
1237
+ * Emulates JavaScript's 32-bit signed integer multiplication (Math.imul),
1238
+ * handling overflow correctly.
1239
+ *
1240
+ * @param int $a The first operand.
1241
+ * @param int $b The second operand.
1242
+ * @return int The 32-bit signed result of a * b.
966
1243
  */
967
1244
  private static function gmp_imul(int $a, int $b): int
968
1245
  {
@@ -974,9 +1251,11 @@ class ChallengeUtils
974
1251
  }
975
1252
 
976
1253
  /**
977
- * Génère une URL piège signée.
978
- * @param string $nonce Le nonce pour signer l'URL.
979
- * @return string L'URL piège.
1254
+ * Generates a signed trap URL. The URL is picked from a template and a
1255
+ * signature is appended as a query parameter.
1256
+ *
1257
+ * @param string $nonce The nonce used to sign the URL.
1258
+ * @return string The trap URL.
980
1259
  */
981
1260
  public static function generateTrapUrl(string $nonce): string
982
1261
  {
@@ -989,11 +1268,13 @@ class ChallengeUtils
989
1268
  }
990
1269
 
991
1270
  /**
992
- * Vérifie si une URL donnée est une URL piège valide pour un nonce donné.
993
- * @param string $path Le chemin de la requête.
994
- * @param string $signature La signature provenant de la query string.
995
- * @param string $nonce Le nonce à vérifier.
996
- * @return bool
1271
+ * Verifies whether a given path and signature form a valid trap URL for
1272
+ * the given nonce. Uses hash_equals for timing-safe comparison.
1273
+ *
1274
+ * @param string $path The request path.
1275
+ * @param string $signature The signature from the query string.
1276
+ * @param string $nonce The nonce to verify against.
1277
+ * @return bool True if the trap URL is valid, false otherwise.
997
1278
  */
998
1279
  public static function verifyTrapUrl(string $path, string $signature, string $nonce): bool
999
1280
  {
@@ -1001,13 +1282,15 @@ class ChallengeUtils
1001
1282
  return false;
1002
1283
  }
1003
1284
  $expectedSignature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
1004
- // Utilise hash_equals pour une comparaison sécurisée contre les attaques temporelles.
1285
+ // Use hash_equals for timing-safe comparison.
1005
1286
  return hash_equals($expectedSignature, $signature);
1006
1287
  }
1007
1288
 
1008
1289
  /**
1009
- * Charge le contenu du solveur JS pour l'injection inline.
1010
- * @return string Le code JavaScript du solveur.
1290
+ * Loads the JavaScript solver code for inline injection into challenge pages.
1291
+ * Searches several candidate paths, including WordPress plugin directories.
1292
+ *
1293
+ * @return string The JavaScript solver code, or an empty string if not found.
1011
1294
  */
1012
1295
  private static function getPowSolverCode(): string
1013
1296
  {
@@ -1030,10 +1313,20 @@ class ChallengeUtils
1030
1313
  return file_get_contents($solverPath) ?: '';
1031
1314
  }
1032
1315
  }
1033
- self::logError("[ChallengeUtils] Erreur: Le fichier pow.solver.inline.js n'a pas été trouvé à l'emplacement attendu.");
1316
+ self::logError("[ChallengeUtils] Error: The pow.solver.inline.js file was not found at the expected location.");
1034
1317
  return '';
1035
1318
  }
1036
1319
 
1320
+ /**
1321
+ * Generates the HTML page for a proof-of-space challenge, embedding the
1322
+ * solver code and the challenge script. The page initializes local storage,
1323
+ * runs the proof-of-space solver, and redirects with the solution.
1324
+ *
1325
+ * @param array $challengeDetails The challenge details (nonce, sizeMb, queries, path, peerId, peerBlockIdx).
1326
+ * @param string $clientSecret The client secret.
1327
+ * @param array $securityConfig The security configuration.
1328
+ * @return string The generated HTML page.
1329
+ */
1037
1330
  public static function generateSpaceChallengePage(array $challengeDetails, string $clientSecret, array $securityConfig): string
1038
1331
  {
1039
1332
  $nonce = $challengeDetails['nonce'];
@@ -1079,14 +1372,21 @@ class ChallengeUtils
1079
1372
  }
1080
1373
 
1081
1374
  /**
1082
- * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
1083
- * @param array $cpuChallengeDetails
1084
- * @param int $memoryDifficulty
1085
- * @param string $clientSecret
1086
- * @param array $securityConfig
1087
- * @param array $trapUrls
1088
- * @param string $originalFingerprint
1089
- * @return string
1375
+ * Generates the HTML page for a combined CPU + Memory proof-of-work challenge.
1376
+ * The page embeds the solver code, the challenge script, and hidden trap links
1377
+ * (rendered in random tags/positions) to lure malicious crawlers.
1378
+ *
1379
+ * @param array $cpuChallengeDetails The CPU challenge details (nonce, target, path).
1380
+ * @param int $memoryDifficulty The memory difficulty in MB (0 to skip).
1381
+ * @param string $clientSecret The client secret.
1382
+ * @param array $securityConfig The security configuration.
1383
+ * @param array $trapUrls The list of trap URLs to embed.
1384
+ * @param string $originalFingerprint The original client fingerprint.
1385
+ * @param string $clientIp The client IP.
1386
+ * @param string $tlsSessionId The TLS session ID.
1387
+ * @param string|null $baseBlock Optional pre-computed base block.
1388
+ * @param bool $isHttps Whether the request is over HTTPS.
1389
+ * @return string The generated HTML page.
1090
1390
  */
1091
1391
  public static function generateCombinedPoWChallengePage(
1092
1392
  array $cpuChallengeDetails,
@@ -1181,6 +1481,12 @@ class ChallengeUtils
1181
1481
  );
1182
1482
  }
1183
1483
 
1484
+ /**
1485
+ * Logs an error message using the PHP error log.
1486
+ *
1487
+ * @param string $message The message to log.
1488
+ * @return void
1489
+ */
1184
1490
  private static function logError(string $message): void
1185
1491
  {
1186
1492
  // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Challenge diagnostic logging