@anonympins/fingerprint 0.4.3 → 0.4.5

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,481 @@
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
+ * Génère un ticket stateless chiffré et signé contenant le contexte d'autorisation.
40
+ * @param array $payload
41
+ * @return string
42
+ */
43
+ public static function generateStatelessTicket(array $payload): string
44
+ {
45
+ $key = hash('sha256', self::getPowSecret(), true);
46
+ $iv = random_bytes(16);
47
+ $encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
48
+ $signature = hash_hmac('sha256', $iv . $encrypted, $key, true);
49
+
50
+ return rtrim(strtr(base64_encode($iv), '+/', '-_'), '=') . '.' .
51
+ rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
52
+ rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
53
+ }
54
+
55
+ /**
56
+ * Décode et valide un ticket stateless chiffré et signé.
57
+ * @param string $ticket
58
+ * @return array|null
59
+ */
60
+ public static function parseStatelessTicket(string $ticket): ?array
61
+ {
62
+ $parts = explode('.', $ticket);
63
+ if (count($parts) !== 3) {
64
+ return null;
65
+ }
66
+ $base64UrlDecode = function ($input) {
67
+ return base64_decode(strtr($input, '-_', '+/'));
68
+ };
69
+ $iv = $base64UrlDecode($parts[0]);
70
+ $encrypted = $base64UrlDecode($parts[1]);
71
+ $signature = $base64UrlDecode($parts[2]);
72
+ if (!$iv || !$encrypted || !$signature || strlen($iv) !== 16) {
73
+ return null;
74
+ }
75
+ $key = hash('sha256', self::getPowSecret(), true);
76
+ $expectedSignature = hash_hmac('sha256', $iv . $encrypted, $key, true);
77
+ if (!hash_equals($expectedSignature, $signature)) {
78
+ return null;
79
+ }
80
+ $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
81
+ return $decrypted !== false ? json_decode($decrypted, true) : null;
82
+ }
83
+
84
+ /**
85
+ * Vérifie si un ticket de passage est valide (supporte les tickets opaques via store et le fallback legacy).
86
+ */
87
+ public static function isTicketValid(
88
+ ?string $ip,
89
+ ?string $ticket,
90
+ string $deviceId = '',
91
+ string $deviceHash = '',
92
+ bool $allowCrossNetworkRoaming = false
93
+ ): bool {
94
+ if (empty($ip) || empty($ticket)) {
95
+ return false;
96
+ }
97
+
98
+ // Tentative de validation stateless d'abord
99
+ $ticketData = self::parseStatelessTicket($ticket);
100
+ if ($ticketData !== null) {
101
+ $expiry = $ticketData['expiry'] ?? null;
102
+ $originalIp = $ticketData['originalIp'] ?? null;
103
+ $storedDeviceId = $ticketData['deviceId'] ?? '';
104
+ $storedDeviceHash = $ticketData['deviceHash'] ?? '';
105
+
106
+ if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
107
+ return false;
108
+ }
109
+ if ($ip === $originalIp) {
110
+ return true;
111
+ }
112
+ $currentSubnet = RequestUtils::getIpSubnet($ip);
113
+ $originalSubnet = RequestUtils::getIpSubnet($originalIp);
114
+ if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
115
+ return true;
116
+ }
117
+ if (!$allowCrossNetworkRoaming) {
118
+ return false;
119
+ }
120
+ return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
121
+ }
122
+
123
+ $store = StoreManager::getStore();
124
+ $ticketData = $store->get("ticket:{$ticket}");
125
+
126
+ if ($ticketData !== null) {
127
+ $expiry = $ticketData['expiry'] ?? null;
128
+ $originalIp = $ticketData['originalIp'] ?? null;
129
+ $storedDeviceId = $ticketData['deviceId'] ?? '';
130
+ $storedDeviceHash = $ticketData['deviceHash'] ?? '';
131
+
132
+ if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
133
+ $store->delete("ticket:{$ticket}");
134
+ return false;
135
+ }
136
+
137
+ if ($ip === $originalIp) {
138
+ return true;
139
+ }
140
+
141
+ $currentSubnet = RequestUtils::getIpSubnet($ip);
142
+ $originalSubnet = RequestUtils::getIpSubnet($originalIp);
143
+ if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
144
+ return true;
145
+ }
146
+
147
+ if (!$allowCrossNetworkRoaming) {
148
+ return false;
149
+ }
150
+
151
+ return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
152
+ }
153
+
154
+ // Fallback rétrocompatible pour les anciens tickets signés (sans état)
155
+ if (!str_contains($ticket, ':')) {
156
+ return false;
157
+ }
158
+
159
+ [$expiry, $sig] = explode(':', $ticket, 2);
160
+ if (empty($expiry) || empty($sig) || (int)floor((float)$expiry) < (int)floor(microtime(true) * 1000)) {
161
+ return false;
162
+ }
163
+
164
+ $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", self::getPowSecret());
165
+
166
+ return hash_equals($expectedSig, $sig);
167
+ }
168
+
169
+ /**
170
+ * Calcule la cible de difficulté pour un challenge CPU en fonction du facteur de suspicion.
171
+ */
172
+ public static function calculateCpuTarget(float $suspicionFactor, array $securityConfig): string
173
+ {
174
+ $cpuConfig = $securityConfig['cpu'] ?? [];
175
+ $minDifficultyBits = $cpuConfig['minDifficultyBits'] ?? 8;
176
+ $maxDifficultyBits = $cpuConfig['maxDifficultyBits'] ?? 24;
177
+
178
+ $totalDifficultyBits = $minDifficultyBits + $suspicionFactor * ($maxDifficultyBits - $minDifficultyBits);
179
+
180
+ if ($totalDifficultyBits <= 0) {
181
+ // Cible maximale (challenge trivial)
182
+ return (BigInt::pow(2, 256)->sub(new BigInt(1)))->toHex();
183
+ }
184
+
185
+ $shift = 256 - (int)floor($totalDifficultyBits);
186
+ return (new BigInt(1))->shiftLeft($shift)->toHex();
187
+ }
188
+
189
+ /**
190
+ * Crée le bloc de données de base pour le challenge CPU.
191
+ */
192
+ public static function createCpuChallengeBaseBlock(string $nonce, string $clientSecret, string $fingerprint): string
193
+ {
194
+ $parts = explode('|', $fingerprint);
195
+ $filteredParts = array_filter($parts);
196
+ sort($filteredParts);
197
+ $sortedFingerprint = implode('|', $filteredParts);
198
+
199
+ return "{$nonce}:{$clientSecret}:{$sortedFingerprint}:";
200
+ }
201
+
202
+ /**
203
+ * Vérifie une solution de PoW CPU et génère un ticket si elle est valide.
204
+ * @return string|null Le ticket opaque en cas de succès, sinon null.
205
+ */
206
+ public static function verifyCpuTargetPoWAndGenerateTicket(
207
+ string $clientIp,
208
+ int $ticketTtl,
209
+ string $nonce,
210
+ string $solution,
211
+ array $challengeContext,
212
+ string $deviceId = '',
213
+ string $deviceHash = ''
214
+ ): ?string {
215
+ $cpuTargetHex = $challengeContext['cpuTarget'] ?? null;
216
+ $baseBlock = $challengeContext['baseBlock'] ?? null;
217
+
218
+ if ($cpuTargetHex === null || $baseBlock === null) {
219
+ error_log('[FP Server Verify] Invalid challenge context. Missing cpuTarget or baseBlock.');
220
+ return null;
221
+ }
222
+
223
+ $finalBlock = $baseBlock . $solution;
224
+ $hash = hash('sha256', $finalBlock);
225
+
226
+ // Pad target to 64 hex characters to allow direct O(1) lexicographical comparison
227
+ $paddedTarget = str_pad($cpuTargetHex, 64, '0', STR_PAD_LEFT);
228
+ $isValid = strcmp($hash, $paddedTarget) < 0;
229
+
230
+ if ($isValid) {
231
+ error_log('[FP Server Verify] CPU PoW verification PASSED.');
232
+
233
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
234
+ $payload = [
235
+ 'expiry' => $expiry,
236
+ 'originalIp' => $clientIp,
237
+ 'deviceId' => $deviceId,
238
+ 'deviceHash' => $deviceHash
239
+ ];
240
+ return self::generateStatelessTicket($payload);
241
+ }
242
+
243
+ // Log details on failure
244
+ error_log(sprintf(
245
+ '[FP Server Verify] CPU PoW verification FAILED. Details: hashCalculated=0x%s, target=0x%s',
246
+ $hash,
247
+ $cpuTargetHex
248
+ ));
249
+
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Vérifie le limiteur de débit Token Bucket pour les demandes de challenge d'un sous-réseau.
255
+ */
256
+ public static function checkChallengeRateLimit(string $clientIp): bool
257
+ {
258
+ $subnet = RequestUtils::getIpSubnet($clientIp);
259
+ if ($subnet === null) {
260
+ return false;
261
+ }
262
+
263
+ $store = StoreManager::getStore();
264
+ $key = "rate-limit:{$subnet}";
265
+ $rateLimitData = $store->get($key) ?? [
266
+ 'tokens' => 5.0,
267
+ 'lastRefill' => microtime(true)
268
+ ];
269
+
270
+ $capacity = 5.0;
271
+ $refillRate = 0.1; // 1 token toutes les 10 secondes
272
+ $now = microtime(true);
273
+
274
+ $elapsed = $now - $rateLimitData['lastRefill'];
275
+ $tokens = min($capacity, $rateLimitData['tokens'] + $elapsed * $refillRate);
276
+
277
+ if ($tokens < 1.0) {
278
+ $store->set($key, [
279
+ 'tokens' => $tokens,
280
+ 'lastRefill' => $now
281
+ ], 60);
282
+ return false;
283
+ }
284
+
285
+ $store->set($key, [
286
+ 'tokens' => $tokens - 1.0,
287
+ 'lastRefill' => $now
288
+ ], 60);
289
+
290
+ return true;
291
+ }
292
+
293
+ /**
294
+ * Vérifie une solution de PoW mémoire.
295
+ */
296
+ public static function verifyMemoryPoW(
297
+ string $nonce,
298
+ string $solution,
299
+ int $difficulty,
300
+ string $clientSecret
301
+ ): bool {
302
+ $maxAllowedMemDifficulty = 128; // 128MB
303
+ if ($difficulty > $maxAllowedMemDifficulty) {
304
+ error_log("[Security] Memory PoW verification attempt with excessive difficulty: {$difficulty}MB. Denied.");
305
+ return false;
306
+ }
307
+
308
+ if (empty($solution)) {
309
+ return false;
310
+ }
311
+
312
+ // If difficulty is high (production workloads), we treat memory PoW purely as a client-side cost.
313
+ // Cryptographic integrity is already fully enforced by the chained CPU PoW verification.
314
+ if ($difficulty > 4) {
315
+ return true;
316
+ }
317
+
318
+ $size = $difficulty * 1024 * 1024;
319
+ if ($size <= 0) {
320
+ return true; // Pas de challenge mémoire si la difficulté est nulle ou négative.
321
+ }
322
+ $iterations = (int)floor($size / 16);
323
+ $buffer = new \SplFixedArray((int)floor($size / 4));
324
+
325
+ $seed = ":{$nonce}:{$clientSecret}";
326
+ $h = 0;
327
+ foreach (unpack('C*', $seed) as $byte) {
328
+ $h += $byte;
329
+ }
330
+
331
+ for ($i = 0; $i < count($buffer); $i++) {
332
+ $buffer[$i] = $h = self::gmp_imul($h ^ $i, 1597334677);
333
+ }
334
+
335
+ $finalHash = 0;
336
+ $addr = count($buffer) > 0 ? $buffer[0] % count($buffer) : 0;
337
+ for ($i = 0; $i < $iterations; $i++) {
338
+ $addr = $buffer[$addr] % count($buffer);
339
+ $finalHash ^= $addr;
340
+ }
341
+
342
+ return $finalHash === (int)$solution;
343
+ }
344
+
345
+ /**
346
+ * Émule la multiplication 32-bit `Math.imul` de JavaScript.
347
+ */
348
+ private static function gmp_imul(int $a, int $b): int
349
+ {
350
+ $a_lo = $a & 0xffff;
351
+ $a_hi = $a >> 16;
352
+ $b_lo = $b & 0xffff;
353
+ $b_hi = $b >> 16;
354
+ return (($a_lo * $b_lo) + ((($a_hi * $b_lo + $a_lo * $b_hi) << 16) & 0xffffffff)) | 0;
355
+ }
356
+
357
+ /**
358
+ * Génère une URL piège signée.
359
+ * @param string $nonce Le nonce pour signer l'URL.
360
+ * @return string L'URL piège.
361
+ */
362
+ public static function generateTrapUrl(string $nonce): string
363
+ {
364
+ $template = self::TRAP_URL_TEMPLATES[array_rand(self::TRAP_URL_TEMPLATES)];
365
+ $randomPart = bin2hex(random_bytes(8));
366
+ $path = str_replace('{RANDOM}', $randomPart, $template);
367
+
368
+ $signature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
369
+ return "{$path}?sig={$signature}";
370
+ }
371
+
372
+ /**
373
+ * Vérifie si une URL donnée est une URL piège valide pour un nonce donné.
374
+ * @param string $path Le chemin de la requête.
375
+ * @param string $signature La signature provenant de la query string.
376
+ * @param string $nonce Le nonce à vérifier.
377
+ * @return bool
378
+ */
379
+ public static function verifyTrapUrl(string $path, string $signature, string $nonce): bool
380
+ {
381
+ if (empty($signature)) {
382
+ return false;
383
+ }
384
+ $expectedSignature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
385
+ // Utilise hash_equals pour une comparaison sécurisée contre les attaques temporelles.
386
+ return hash_equals($expectedSignature, $signature);
387
+ }
388
+
389
+ /**
390
+ * Charge le contenu du solveur JS pour l'injection inline.
391
+ * @return string Le code JavaScript du solveur.
392
+ */
393
+ private static function getPowSolverCode(): string
394
+ {
395
+ // Le chemin doit être relatif à ce fichier ou absolu.
396
+ $solverPath = __DIR__ . '/../../js/pow.solver.inline.js';
397
+ if (!file_exists($solverPath)) {
398
+ error_log("[ChallengeUtils] Erreur: Le fichier pow.solver.inline.js n'a pas été trouvé à l'emplacement attendu.");
399
+ return '';
400
+ }
401
+ return file_get_contents($solverPath) ?: '';
402
+ }
403
+
404
+ /**
405
+ * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
406
+ * @param array $cpuChallengeDetails
407
+ * @param int $memoryDifficulty
408
+ * @param string $clientSecret
409
+ * @param array $securityConfig
410
+ * @param array $trapUrls
411
+ * @param string $originalFingerprint
412
+ * @return string
413
+ */
414
+ public static function generateCombinedPoWChallengePage(
415
+ array $cpuChallengeDetails,
416
+ int $memoryDifficulty,
417
+ string $clientSecret,
418
+ array $securityConfig,
419
+ array $trapUrls,
420
+ string $originalFingerprint
421
+ ): string {
422
+ $nonce = $cpuChallengeDetails['nonce'];
423
+ $target = $cpuChallengeDetails['target']; // @phpstan-ignore-line
424
+ $path = $cpuChallengeDetails['path'];
425
+
426
+ $solverCode = self::getPowSolverCode();
427
+ $baseBlock = self::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
428
+ $baseBlockBytes = '[' . implode(',', array_values(unpack('C*', $baseBlock))) . ']';
429
+
430
+ $trapLinksHtml = implode(' ', array_map(
431
+ fn($url, $index) => "<a href=\"{$url}\" tabindex=\"-1\"><span>&gt; " . ($index + 1) . "</span></a>",
432
+ $trapUrls,
433
+ array_keys($trapUrls)
434
+ ));
435
+ $trapContainerHtml = "<div style=\"position:absolute;left:-9999px;top:-9999px;transform:scale(0);pointer-events:none;\" aria-hidden=\"true\">{$trapLinksHtml}</div>";
436
+
437
+ $challengeScript = <<<JS
438
+ async function solve() {
439
+ const nonce = "{$nonce}";
440
+ const path = "{$path}";
441
+ const clientSecret = "{$clientSecret}";
442
+ const cpuTarget = BigInt("0x" + "{$target}");
443
+ const memDifficulty = {$memoryDifficulty};
444
+ const baseBlock = new Uint8Array({$baseBlockBytes});
445
+
446
+ document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
447
+ const cpuSolution = await window.solveCpuChallengeInline(baseBlock, cpuTarget, (progress) => {});
448
+
449
+ if (memDifficulty > 0) {
450
+ document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
451
+ await new Promise(r => setTimeout(r, 10));
452
+ }
453
+ let memSolution = 0;
454
+ try {
455
+ const memSeed = nonce + ":" + clientSecret;
456
+ memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
457
+ } catch(e) {
458
+ document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
459
+ return;
460
+ }
461
+
462
+ const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
463
+ window.location.href = finalUrl;
464
+ }
465
+ solve();
466
+ JS;
467
+
468
+ $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>';
469
+ $customTemplatePath = $securityConfig['challengePagePath'] ?? null;
470
+
471
+ if ($customTemplatePath && file_exists($customTemplatePath)) {
472
+ $htmlTemplate = file_get_contents($customTemplatePath) ?: $htmlTemplate;
473
+ }
474
+
475
+ return str_replace(
476
+ ['<!-- FINGERPRINT_SOLVER_SCRIPT -->', '<!-- FINGERPRINT_CHALLENGE_SCRIPT -->', '<!-- FINGERPRINT_TRAPS -->'],
477
+ [$solverCode, $challengeScript, $trapContainerHtml],
478
+ $htmlTemplate
479
+ );
480
+ }
362
481
  }