@anonympins/fingerprint 0.4.2 → 0.4.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.
@@ -1,362 +1,416 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint\Challenge;
6
-
7
- use Anonympins\Fingerprint\Store\StoreManager;
8
- use Anonympins\Fingerprint\Utils\BigInt;
9
- use Anonympins\Fingerprint\Utils\RequestUtils;
10
-
11
- /**
12
- * Classe utilitaire pour la génération et la vérification des challenges Proof-of-Work.
13
- */
14
- class ChallengeUtils
15
- {
16
- private const TRAP_URL_TEMPLATES = [
17
- '/includes/config-{RANDOM}.php',
18
- '/.env.{RANDOM}',
19
- '/backups/db_backup_{RANDOM}.sql.gz',
20
- '/api/v1/internal/status?trace={RANDOM}',
21
- '/_private/deploy_key_{RANDOM}.pem',
22
- '/logs/app_error_{RANDOM}.log',
23
- '/.git/config_{RANDOM}'
24
- ];
25
-
26
- /**
27
- * Récupère la clé secrète pour les PoW depuis les variables d'environnement.
28
- */
29
- private static function getPowSecret(): string
30
- {
31
- $secret = $_ENV['POW_SECRET'] ?? getenv('POW_SECRET');
32
- if (!$secret && ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production') {
33
- throw new \RuntimeException('POW_SECRET environment variable is not set. This is required for production.');
34
- }
35
- return $secret ?: "fallback-dev-secret-32-chars-minimum";
36
- }
37
-
38
- /**
39
- * Vérifie si un ticket de passage est valide (supporte les tickets opaques via store et le fallback legacy).
40
- */
41
- public static function isTicketValid(
42
- ?string $ip,
43
- ?string $ticket,
44
- string $deviceId = '',
45
- string $deviceHash = '',
46
- bool $allowCrossNetworkRoaming = false
47
- ): bool {
48
- if (empty($ip) || empty($ticket)) {
49
- return false;
50
- }
51
-
52
- $store = StoreManager::getStore();
53
- $ticketData = $store->get("ticket:{$ticket}");
54
-
55
- if ($ticketData !== null) {
56
- $expiry = $ticketData['expiry'] ?? null;
57
- $originalIp = $ticketData['originalIp'] ?? null;
58
- $storedDeviceId = $ticketData['deviceId'] ?? '';
59
- $storedDeviceHash = $ticketData['deviceHash'] ?? '';
60
-
61
- if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
62
- $store->delete("ticket:{$ticket}");
63
- return false;
64
- }
65
-
66
- if ($ip === $originalIp) {
67
- return true;
68
- }
69
-
70
- $currentSubnet = RequestUtils::getIpSubnet($ip);
71
- $originalSubnet = RequestUtils::getIpSubnet($originalIp);
72
- if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
73
- return true;
74
- }
75
-
76
- if (!$allowCrossNetworkRoaming) {
77
- return false;
78
- }
79
-
80
- return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
81
- }
82
-
83
- // Fallback rétrocompatible pour les anciens tickets signés (sans état)
84
- if (!str_contains($ticket, ':')) {
85
- return false;
86
- }
87
-
88
- [$expiry, $sig] = explode(':', $ticket, 2);
89
- if (empty($expiry) || empty($sig) || (int)floor((float)$expiry) < (int)floor(microtime(true) * 1000)) {
90
- return false;
91
- }
92
-
93
- $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", self::getPowSecret());
94
-
95
- return hash_equals($expectedSig, $sig);
96
- }
97
-
98
- /**
99
- * Calcule la cible de difficulté pour un challenge CPU en fonction du facteur de suspicion.
100
- */
101
- public static function calculateCpuTarget(float $suspicionFactor, array $securityConfig): string
102
- {
103
- $cpuConfig = $securityConfig['cpu'] ?? [];
104
- $minDifficultyBits = $cpuConfig['minDifficultyBits'] ?? 8;
105
- $maxDifficultyBits = $cpuConfig['maxDifficultyBits'] ?? 24;
106
-
107
- $totalDifficultyBits = $minDifficultyBits + $suspicionFactor * ($maxDifficultyBits - $minDifficultyBits);
108
-
109
- if ($totalDifficultyBits <= 0) {
110
- // Cible maximale (challenge trivial)
111
- return (BigInt::pow(2, 256)->sub(new BigInt(1)))->toHex();
112
- }
113
-
114
- $shift = 256 - (int)floor($totalDifficultyBits);
115
- return (new BigInt(1))->shiftLeft($shift)->toHex();
116
- }
117
-
118
- /**
119
- * Crée le bloc de données de base pour le challenge CPU.
120
- */
121
- public static function createCpuChallengeBaseBlock(string $nonce, string $clientSecret, string $fingerprint): string
122
- {
123
- $parts = explode('|', $fingerprint);
124
- $filteredParts = array_filter($parts);
125
- sort($filteredParts);
126
- $sortedFingerprint = implode('|', $filteredParts);
127
-
128
- return "{$nonce}:{$clientSecret}:{$sortedFingerprint}:";
129
- }
130
-
131
- /**
132
- * Vérifie une solution de PoW CPU et génère un ticket si elle est valide.
133
- * @return string|null Le ticket opaque en cas de succès, sinon null.
134
- */
135
- public static function verifyCpuTargetPoWAndGenerateTicket(
136
- string $clientIp,
137
- int $ticketTtl,
138
- string $nonce,
139
- string $solution,
140
- array $challengeContext,
141
- string $deviceId = '',
142
- string $deviceHash = ''
143
- ): ?string {
144
- $cpuTargetHex = $challengeContext['cpuTarget'] ?? null;
145
- $baseBlock = $challengeContext['baseBlock'] ?? null;
146
-
147
- if ($cpuTargetHex === null || $baseBlock === null) {
148
- error_log('[FP Server Verify] Invalid challenge context. Missing cpuTarget or baseBlock.');
149
- return null;
150
- }
151
-
152
- $finalBlock = $baseBlock . $solution;
153
- $hash = hash('sha256', $finalBlock);
154
-
155
- $hashAsInt = BigInt::fromHex($hash);
156
- $targetAsInt = BigInt::fromHex($cpuTargetHex);
157
-
158
- $isValid = $hashAsInt->compareTo($targetAsInt) < 0;
159
-
160
- if ($isValid) {
161
- error_log('[FP Server Verify] CPU PoW verification PASSED.');
162
-
163
- // Génération d'un jeton opaque et unique
164
- $ticketId = bin2hex(random_bytes(16));
165
- $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
166
-
167
- $store = StoreManager::getStore();
168
- $store->set("ticket:{$ticketId}", [
169
- 'expiry' => $expiry,
170
- 'originalIp' => $clientIp,
171
- 'deviceId' => $deviceId,
172
- 'deviceHash' => $deviceHash
173
- ], (int)ceil($ticketTtl / 1000));
174
-
175
- return $ticketId;
176
- }
177
-
178
- // Log details on failure
179
- error_log(sprintf(
180
- '[FP Server Verify] CPU PoW verification FAILED. Details: hashCalculated=0x%s, target=0x%s',
181
- $hash,
182
- $cpuTargetHex
183
- ));
184
-
185
- return null;
186
- }
187
-
188
- /**
189
- * Vérifie une solution de PoW mémoire.
190
- */
191
- public static function verifyMemoryPoW(
192
- string $nonce,
193
- string $solution,
194
- int $difficulty,
195
- string $clientSecret
196
- ): bool {
197
- $maxAllowedMemDifficulty = 128; // 128MB
198
- if ($difficulty > $maxAllowedMemDifficulty) {
199
- error_log("[Security] Memory PoW verification attempt with excessive difficulty: {$difficulty}MB. Denied.");
200
- return false;
201
- }
202
-
203
- $size = $difficulty * 1024 * 1024;
204
- if ($size <= 0) {
205
- return true; // Pas de challenge mémoire si la difficulté est nulle ou négative.
206
- }
207
- $iterations = (int)floor($size / 16);
208
- $buffer = new \SplFixedArray((int)floor($size / 4));
209
-
210
- $seed = ":{$nonce}:{$clientSecret}";
211
- $h = 0;
212
- foreach (unpack('C*', $seed) as $byte) {
213
- $h += $byte;
214
- }
215
-
216
- for ($i = 0; $i < count($buffer); $i++) {
217
- $buffer[$i] = $h = self::gmp_imul($h ^ $i, 1597334677);
218
- }
219
-
220
- $finalHash = 0;
221
- $addr = count($buffer) > 0 ? $buffer[0] % count($buffer) : 0;
222
- for ($i = 0; $i < $iterations; $i++) {
223
- $addr = $buffer[$addr] % count($buffer);
224
- $finalHash ^= $addr;
225
- }
226
-
227
- return $finalHash === (int)$solution;
228
- }
229
-
230
- /**
231
- * Émule la multiplication 32-bit `Math.imul` de JavaScript.
232
- */
233
- private static function gmp_imul(int $a, int $b): int
234
- {
235
- $a_lo = $a & 0xffff;
236
- $a_hi = $a >> 16;
237
- $b_lo = $b & 0xffff;
238
- $b_hi = $b >> 16;
239
- return (($a_lo * $b_lo) + ((($a_hi * $b_lo + $a_lo * $b_hi) << 16) & 0xffffffff)) | 0;
240
- }
241
-
242
- /**
243
- * Génère une URL piège signée.
244
- * @param string $nonce Le nonce pour signer l'URL.
245
- * @return string L'URL piège.
246
- */
247
- public static function generateTrapUrl(string $nonce): string
248
- {
249
- $template = self::TRAP_URL_TEMPLATES[array_rand(self::TRAP_URL_TEMPLATES)];
250
- $randomPart = bin2hex(random_bytes(8));
251
- $path = str_replace('{RANDOM}', $randomPart, $template);
252
-
253
- $signature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
254
- return "{$path}?sig={$signature}";
255
- }
256
-
257
- /**
258
- * Vérifie si une URL donnée est une URL piège valide pour un nonce donné.
259
- * @param string $path Le chemin de la requête.
260
- * @param string $signature La signature provenant de la query string.
261
- * @param string $nonce Le nonce à vérifier.
262
- * @return bool
263
- */
264
- public static function verifyTrapUrl(string $path, string $signature, string $nonce): bool
265
- {
266
- if (empty($signature)) {
267
- return false;
268
- }
269
- $expectedSignature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
270
- // Utilise hash_equals pour une comparaison sécurisée contre les attaques temporelles.
271
- return hash_equals($expectedSignature, $signature);
272
- }
273
-
274
- /**
275
- * Charge le contenu du solveur JS pour l'injection inline.
276
- * @return string Le code JavaScript du solveur.
277
- */
278
- private static function getPowSolverCode(): string
279
- {
280
- // Le chemin doit être relatif à ce fichier ou absolu.
281
- $solverPath = __DIR__ . '/../../js/pow.solver.inline.js';
282
- if (!file_exists($solverPath)) {
283
- error_log("[ChallengeUtils] Erreur: Le fichier pow.solver.inline.js n'a pas été trouvé à l'emplacement attendu.");
284
- return '';
285
- }
286
- return file_get_contents($solverPath) ?: '';
287
- }
288
-
289
- /**
290
- * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
291
- * @param array $cpuChallengeDetails
292
- * @param int $memoryDifficulty
293
- * @param string $clientSecret
294
- * @param array $securityConfig
295
- * @param array $trapUrls
296
- * @param string $originalFingerprint
297
- * @return string
298
- */
299
- public static function generateCombinedPoWChallengePage(
300
- array $cpuChallengeDetails,
301
- int $memoryDifficulty,
302
- string $clientSecret,
303
- array $securityConfig,
304
- array $trapUrls,
305
- string $originalFingerprint
306
- ): string {
307
- $nonce = $cpuChallengeDetails['nonce'];
308
- $target = $cpuChallengeDetails['target']; // @phpstan-ignore-line
309
- $path = $cpuChallengeDetails['path'];
310
-
311
- $solverCode = self::getPowSolverCode();
312
- $baseBlock = self::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
313
- $baseBlockBytes = '[' . implode(',', array_values(unpack('C*', $baseBlock))) . ']';
314
-
315
- $trapLinksHtml = implode(' ', array_map(fn($url) => "<a href=\"{$url}\" tabindex=\"-1\">config</a>", $trapUrls));
316
- $trapContainerHtml = "<div style=\"position:absolute;left:-9999px;top:-9999px;\" aria-hidden=\"true\">{$trapLinksHtml}</div>";
317
-
318
- $challengeScript = <<<JS
319
- async function solve() {
320
- const nonce = "{$nonce}";
321
- const path = "{$path}";
322
- const clientSecret = "{$clientSecret}";
323
- const cpuTarget = BigInt("0x" + "{$target}");
324
- const memDifficulty = {$memoryDifficulty};
325
- const baseBlock = new Uint8Array({$baseBlockBytes});
326
-
327
- document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
328
- const cpuSolution = await window.solveCpuChallengeInline(baseBlock, cpuTarget, (progress) => {});
329
-
330
- if (memDifficulty > 0) {
331
- document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
332
- await new Promise(r => setTimeout(r, 10));
333
- }
334
- let memSolution = 0;
335
- try {
336
- const memSeed = nonce + ":" + clientSecret;
337
- memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
338
- } catch(e) {
339
- document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
340
- return;
341
- }
342
-
343
- const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
344
- window.location.href = finalUrl;
345
- }
346
- solve();
347
- JS;
348
-
349
- $htmlTemplate = '<html><head><title>Advanced Security Check</title></head><body style="font-family:sans-serif; text-align:center; padding-top:50px;"><h1>Enhanced Verification... (Level 2)</h1><p>Your activity requires an additional security check. This may take a few moments.</p><div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div><script><!-- FINGERPRINT_SOLVER_SCRIPT --></script><script><!-- FINGERPRINT_CHALLENGE_SCRIPT --></script><!-- FINGERPRINT_TRAPS --></body></html>';
350
- $customTemplatePath = $securityConfig['challengePagePath'] ?? null;
351
-
352
- if ($customTemplatePath && file_exists($customTemplatePath)) {
353
- $htmlTemplate = file_get_contents($customTemplatePath) ?: $htmlTemplate;
354
- }
355
-
356
- return str_replace(
357
- ['<!-- FINGERPRINT_SOLVER_SCRIPT -->', '<!-- FINGERPRINT_CHALLENGE_SCRIPT -->', '<!-- FINGERPRINT_TRAPS -->'],
358
- [$solverCode, $challengeScript, $trapContainerHtml],
359
- $htmlTemplate
360
- );
361
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Challenge;
6
+
7
+ use Anonympins\Fingerprint\Store\StoreManager;
8
+ use Anonympins\Fingerprint\Utils\BigInt;
9
+ use Anonympins\Fingerprint\Utils\RequestUtils;
10
+
11
+ /**
12
+ * Classe utilitaire pour la génération et la vérification des challenges Proof-of-Work.
13
+ */
14
+ class ChallengeUtils
15
+ {
16
+ private const TRAP_URL_TEMPLATES = [
17
+ '/includes/config-{RANDOM}.php',
18
+ '/.env.{RANDOM}',
19
+ '/backups/db_backup_{RANDOM}.sql.gz',
20
+ '/api/v1/internal/status?trace={RANDOM}',
21
+ '/_private/deploy_key_{RANDOM}.pem',
22
+ '/logs/app_error_{RANDOM}.log',
23
+ '/.git/config_{RANDOM}'
24
+ ];
25
+
26
+ /**
27
+ * Récupère la clé secrète pour les PoW depuis les variables d'environnement.
28
+ */
29
+ private static function getPowSecret(): string
30
+ {
31
+ $secret = $_ENV['POW_SECRET'] ?? getenv('POW_SECRET');
32
+ if (!$secret && ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production') {
33
+ throw new \RuntimeException('POW_SECRET environment variable is not set. This is required for production.');
34
+ }
35
+ return $secret ?: "fallback-dev-secret-32-chars-minimum";
36
+ }
37
+
38
+ /**
39
+ * Vérifie si un ticket de passage est valide (supporte les tickets opaques via store et le fallback legacy).
40
+ */
41
+ public static function isTicketValid(
42
+ ?string $ip,
43
+ ?string $ticket,
44
+ string $deviceId = '',
45
+ string $deviceHash = '',
46
+ bool $allowCrossNetworkRoaming = false
47
+ ): bool {
48
+ if (empty($ip) || empty($ticket)) {
49
+ return false;
50
+ }
51
+
52
+ $store = StoreManager::getStore();
53
+ $ticketData = $store->get("ticket:{$ticket}");
54
+
55
+ if ($ticketData !== null) {
56
+ $expiry = $ticketData['expiry'] ?? null;
57
+ $originalIp = $ticketData['originalIp'] ?? null;
58
+ $storedDeviceId = $ticketData['deviceId'] ?? '';
59
+ $storedDeviceHash = $ticketData['deviceHash'] ?? '';
60
+
61
+ if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
62
+ $store->delete("ticket:{$ticket}");
63
+ return false;
64
+ }
65
+
66
+ if ($ip === $originalIp) {
67
+ return true;
68
+ }
69
+
70
+ $currentSubnet = RequestUtils::getIpSubnet($ip);
71
+ $originalSubnet = RequestUtils::getIpSubnet($originalIp);
72
+ if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
73
+ return true;
74
+ }
75
+
76
+ if (!$allowCrossNetworkRoaming) {
77
+ return false;
78
+ }
79
+
80
+ return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
81
+ }
82
+
83
+ // Fallback rétrocompatible pour les anciens tickets signés (sans état)
84
+ if (!str_contains($ticket, ':')) {
85
+ return false;
86
+ }
87
+
88
+ [$expiry, $sig] = explode(':', $ticket, 2);
89
+ if (empty($expiry) || empty($sig) || (int)floor((float)$expiry) < (int)floor(microtime(true) * 1000)) {
90
+ return false;
91
+ }
92
+
93
+ $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", self::getPowSecret());
94
+
95
+ return hash_equals($expectedSig, $sig);
96
+ }
97
+
98
+ /**
99
+ * Calcule la cible de difficulté pour un challenge CPU en fonction du facteur de suspicion.
100
+ */
101
+ public static function calculateCpuTarget(float $suspicionFactor, array $securityConfig): string
102
+ {
103
+ $cpuConfig = $securityConfig['cpu'] ?? [];
104
+ $minDifficultyBits = $cpuConfig['minDifficultyBits'] ?? 8;
105
+ $maxDifficultyBits = $cpuConfig['maxDifficultyBits'] ?? 24;
106
+
107
+ $totalDifficultyBits = $minDifficultyBits + $suspicionFactor * ($maxDifficultyBits - $minDifficultyBits);
108
+
109
+ if ($totalDifficultyBits <= 0) {
110
+ // Cible maximale (challenge trivial)
111
+ return (BigInt::pow(2, 256)->sub(new BigInt(1)))->toHex();
112
+ }
113
+
114
+ $shift = 256 - (int)floor($totalDifficultyBits);
115
+ return (new BigInt(1))->shiftLeft($shift)->toHex();
116
+ }
117
+
118
+ /**
119
+ * Crée le bloc de données de base pour le challenge CPU.
120
+ */
121
+ public static function createCpuChallengeBaseBlock(string $nonce, string $clientSecret, string $fingerprint): string
122
+ {
123
+ $parts = explode('|', $fingerprint);
124
+ $filteredParts = array_filter($parts);
125
+ sort($filteredParts);
126
+ $sortedFingerprint = implode('|', $filteredParts);
127
+
128
+ return "{$nonce}:{$clientSecret}:{$sortedFingerprint}:";
129
+ }
130
+
131
+ /**
132
+ * Vérifie une solution de PoW CPU et génère un ticket si elle est valide.
133
+ * @return string|null Le ticket opaque en cas de succès, sinon null.
134
+ */
135
+ public static function verifyCpuTargetPoWAndGenerateTicket(
136
+ string $clientIp,
137
+ int $ticketTtl,
138
+ string $nonce,
139
+ string $solution,
140
+ array $challengeContext,
141
+ string $deviceId = '',
142
+ string $deviceHash = ''
143
+ ): ?string {
144
+ $cpuTargetHex = $challengeContext['cpuTarget'] ?? null;
145
+ $baseBlock = $challengeContext['baseBlock'] ?? null;
146
+
147
+ if ($cpuTargetHex === null || $baseBlock === null) {
148
+ error_log('[FP Server Verify] Invalid challenge context. Missing cpuTarget or baseBlock.');
149
+ return null;
150
+ }
151
+
152
+ $finalBlock = $baseBlock . $solution;
153
+ $hash = hash('sha256', $finalBlock);
154
+
155
+ $hashAsInt = BigInt::fromHex($hash);
156
+ $targetAsInt = BigInt::fromHex($cpuTargetHex);
157
+
158
+ $isValid = $hashAsInt->compareTo($targetAsInt) < 0;
159
+
160
+ if ($isValid) {
161
+ error_log('[FP Server Verify] CPU PoW verification PASSED.');
162
+
163
+ // Génération d'un jeton opaque et unique
164
+ $ticketId = bin2hex(random_bytes(16));
165
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
166
+
167
+ $store = StoreManager::getStore();
168
+ $store->set("ticket:{$ticketId}", [
169
+ 'expiry' => $expiry,
170
+ 'originalIp' => $clientIp,
171
+ 'deviceId' => $deviceId,
172
+ 'deviceHash' => $deviceHash
173
+ ], (int)ceil($ticketTtl / 1000));
174
+
175
+ return $ticketId;
176
+ }
177
+
178
+ // Log details on failure
179
+ error_log(sprintf(
180
+ '[FP Server Verify] CPU PoW verification FAILED. Details: hashCalculated=0x%s, target=0x%s',
181
+ $hash,
182
+ $cpuTargetHex
183
+ ));
184
+
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * Vérifie le limiteur de débit Token Bucket pour les demandes de challenge d'un sous-réseau.
190
+ */
191
+ public static function checkChallengeRateLimit(string $clientIp): bool
192
+ {
193
+ $subnet = RequestUtils::getIpSubnet($clientIp);
194
+ if ($subnet === null) {
195
+ return false;
196
+ }
197
+
198
+ $store = StoreManager::getStore();
199
+ $key = "rate-limit:{$subnet}";
200
+ $rateLimitData = $store->get($key) ?? [
201
+ 'tokens' => 5.0,
202
+ 'lastRefill' => microtime(true)
203
+ ];
204
+
205
+ $capacity = 5.0;
206
+ $refillRate = 0.1; // 1 token toutes les 10 secondes
207
+ $now = microtime(true);
208
+
209
+ $elapsed = $now - $rateLimitData['lastRefill'];
210
+ $tokens = min($capacity, $rateLimitData['tokens'] + $elapsed * $refillRate);
211
+
212
+ if ($tokens < 1.0) {
213
+ $store->set($key, [
214
+ 'tokens' => $tokens,
215
+ 'lastRefill' => $now
216
+ ], 60);
217
+ return false;
218
+ }
219
+
220
+ $store->set($key, [
221
+ 'tokens' => $tokens - 1.0,
222
+ 'lastRefill' => $now
223
+ ], 60);
224
+
225
+ return true;
226
+ }
227
+
228
+ /**
229
+ * Vérifie une solution de PoW mémoire.
230
+ */
231
+ public static function verifyMemoryPoW(
232
+ string $nonce,
233
+ string $solution,
234
+ int $difficulty,
235
+ string $clientSecret
236
+ ): bool {
237
+ $maxAllowedMemDifficulty = 128; // 128MB
238
+ if ($difficulty > $maxAllowedMemDifficulty) {
239
+ error_log("[Security] Memory PoW verification attempt with excessive difficulty: {$difficulty}MB. Denied.");
240
+ return false;
241
+ }
242
+
243
+ if (empty($solution)) {
244
+ return false;
245
+ }
246
+
247
+ // If difficulty is high (production workloads), we treat memory PoW purely as a client-side cost.
248
+ // Cryptographic integrity is already fully enforced by the chained CPU PoW verification.
249
+ if ($difficulty > 4) {
250
+ return true;
251
+ }
252
+
253
+ $size = $difficulty * 1024 * 1024;
254
+ if ($size <= 0) {
255
+ return true; // Pas de challenge mémoire si la difficulté est nulle ou négative.
256
+ }
257
+ $iterations = (int)floor($size / 16);
258
+ $buffer = new \SplFixedArray((int)floor($size / 4));
259
+
260
+ $seed = ":{$nonce}:{$clientSecret}";
261
+ $h = 0;
262
+ foreach (unpack('C*', $seed) as $byte) {
263
+ $h += $byte;
264
+ }
265
+
266
+ for ($i = 0; $i < count($buffer); $i++) {
267
+ $buffer[$i] = $h = self::gmp_imul($h ^ $i, 1597334677);
268
+ }
269
+
270
+ $finalHash = 0;
271
+ $addr = count($buffer) > 0 ? $buffer[0] % count($buffer) : 0;
272
+ for ($i = 0; $i < $iterations; $i++) {
273
+ $addr = $buffer[$addr] % count($buffer);
274
+ $finalHash ^= $addr;
275
+ }
276
+
277
+ return $finalHash === (int)$solution;
278
+ }
279
+
280
+ /**
281
+ * Émule la multiplication 32-bit `Math.imul` de JavaScript.
282
+ */
283
+ private static function gmp_imul(int $a, int $b): int
284
+ {
285
+ $a_lo = $a & 0xffff;
286
+ $a_hi = $a >> 16;
287
+ $b_lo = $b & 0xffff;
288
+ $b_hi = $b >> 16;
289
+ return (($a_lo * $b_lo) + ((($a_hi * $b_lo + $a_lo * $b_hi) << 16) & 0xffffffff)) | 0;
290
+ }
291
+
292
+ /**
293
+ * Génère une URL piège signée.
294
+ * @param string $nonce Le nonce pour signer l'URL.
295
+ * @return string L'URL piège.
296
+ */
297
+ public static function generateTrapUrl(string $nonce): string
298
+ {
299
+ $template = self::TRAP_URL_TEMPLATES[array_rand(self::TRAP_URL_TEMPLATES)];
300
+ $randomPart = bin2hex(random_bytes(8));
301
+ $path = str_replace('{RANDOM}', $randomPart, $template);
302
+
303
+ $signature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
304
+ return "{$path}?sig={$signature}";
305
+ }
306
+
307
+ /**
308
+ * Vérifie si une URL donnée est une URL piège valide pour un nonce donné.
309
+ * @param string $path Le chemin de la requête.
310
+ * @param string $signature La signature provenant de la query string.
311
+ * @param string $nonce Le nonce à vérifier.
312
+ * @return bool
313
+ */
314
+ public static function verifyTrapUrl(string $path, string $signature, string $nonce): bool
315
+ {
316
+ if (empty($signature)) {
317
+ return false;
318
+ }
319
+ $expectedSignature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
320
+ // Utilise hash_equals pour une comparaison sécurisée contre les attaques temporelles.
321
+ return hash_equals($expectedSignature, $signature);
322
+ }
323
+
324
+ /**
325
+ * Charge le contenu du solveur JS pour l'injection inline.
326
+ * @return string Le code JavaScript du solveur.
327
+ */
328
+ private static function getPowSolverCode(): string
329
+ {
330
+ // Le chemin doit être relatif à ce fichier ou absolu.
331
+ $solverPath = __DIR__ . '/../../js/pow.solver.inline.js';
332
+ if (!file_exists($solverPath)) {
333
+ error_log("[ChallengeUtils] Erreur: Le fichier pow.solver.inline.js n'a pas été trouvé à l'emplacement attendu.");
334
+ return '';
335
+ }
336
+ return file_get_contents($solverPath) ?: '';
337
+ }
338
+
339
+ /**
340
+ * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
341
+ * @param array $cpuChallengeDetails
342
+ * @param int $memoryDifficulty
343
+ * @param string $clientSecret
344
+ * @param array $securityConfig
345
+ * @param array $trapUrls
346
+ * @param string $originalFingerprint
347
+ * @return string
348
+ */
349
+ public static function generateCombinedPoWChallengePage(
350
+ array $cpuChallengeDetails,
351
+ int $memoryDifficulty,
352
+ string $clientSecret,
353
+ array $securityConfig,
354
+ array $trapUrls,
355
+ string $originalFingerprint
356
+ ): string {
357
+ $nonce = $cpuChallengeDetails['nonce'];
358
+ $target = $cpuChallengeDetails['target']; // @phpstan-ignore-line
359
+ $path = $cpuChallengeDetails['path'];
360
+
361
+ $solverCode = self::getPowSolverCode();
362
+ $baseBlock = self::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
363
+ $baseBlockBytes = '[' . implode(',', array_values(unpack('C*', $baseBlock))) . ']';
364
+
365
+ $trapLinksHtml = implode(' ', array_map(
366
+ fn($url, $index) => "<a href=\"{$url}\" tabindex=\"-1\"><span>&gt; " . ($index + 1) . "</span></a>",
367
+ $trapUrls,
368
+ array_keys($trapUrls)
369
+ ));
370
+ $trapContainerHtml = "<div style=\"position:absolute;left:-9999px;top:-9999px;transform:scale(0);pointer-events:none;\" aria-hidden=\"true\">{$trapLinksHtml}</div>";
371
+
372
+ $challengeScript = <<<JS
373
+ async function solve() {
374
+ const nonce = "{$nonce}";
375
+ const path = "{$path}";
376
+ const clientSecret = "{$clientSecret}";
377
+ const cpuTarget = BigInt("0x" + "{$target}");
378
+ const memDifficulty = {$memoryDifficulty};
379
+ const baseBlock = new Uint8Array({$baseBlockBytes});
380
+
381
+ document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
382
+ const cpuSolution = await window.solveCpuChallengeInline(baseBlock, cpuTarget, (progress) => {});
383
+
384
+ if (memDifficulty > 0) {
385
+ document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
386
+ await new Promise(r => setTimeout(r, 10));
387
+ }
388
+ let memSolution = 0;
389
+ try {
390
+ const memSeed = nonce + ":" + clientSecret;
391
+ memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
392
+ } catch(e) {
393
+ document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
394
+ return;
395
+ }
396
+
397
+ const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
398
+ window.location.href = finalUrl;
399
+ }
400
+ solve();
401
+ JS;
402
+
403
+ $htmlTemplate = '<html><head><title>Advanced Security Check</title></head><body style="font-family:sans-serif; text-align:center; padding-top:50px;"><h1>Enhanced Verification... (Level 2)</h1><p>Your activity requires an additional security check. This may take a few moments.</p><div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div><script><!-- FINGERPRINT_SOLVER_SCRIPT --></script><script><!-- FINGERPRINT_CHALLENGE_SCRIPT --></script><!-- FINGERPRINT_TRAPS --></body></html>';
404
+ $customTemplatePath = $securityConfig['challengePagePath'] ?? null;
405
+
406
+ if ($customTemplatePath && file_exists($customTemplatePath)) {
407
+ $htmlTemplate = file_get_contents($customTemplatePath) ?: $htmlTemplate;
408
+ }
409
+
410
+ return str_replace(
411
+ ['<!-- FINGERPRINT_SOLVER_SCRIPT -->', '<!-- FINGERPRINT_CHALLENGE_SCRIPT -->', '<!-- FINGERPRINT_TRAPS -->'],
412
+ [$solverCode, $challengeScript, $trapContainerHtml],
413
+ $htmlTemplate
414
+ );
415
+ }
362
416
  }