@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.
- package/CHANGELOG.md +38 -0
- package/README.md +70 -60
- package/package.json +1 -1
- package/src/js/fingerprint.client.js +831 -635
- package/src/js/fingerprint.js +249 -31
- package/src/js/pow.solver.js +531 -497
- package/src/js/problem-manager.js +24 -0
- package/src/js/tests/fingerprint.client.init.test.js +140 -119
- package/src/js/tests/fingerprint.test.js +2451 -2319
- package/src/js/tests/pow.solver.test.js +233 -197
- package/src/js/tests/problem-manager.test.js +358 -322
- package/src/php/Challenge/ChallengeUtils.php +415 -361
- package/src/php/Config/SecurityProfiles.php +276 -271
- package/src/php/FingerprintClient.php +131 -131
- package/src/php/FingerprintEngine.php +36 -2
- package/src/php/Optimization/FunctionRegistry.php +63 -62
- package/src/php/Optimization/OptimizationOperators.php +401 -304
- package/src/php/ProblemManager.php +29 -0
- package/src/php/RequestContext.php +90 -90
- package/src/php/Store/IStore.php +41 -41
- package/src/php/Store/RedisStore.php +53 -53
- package/src/php/Tests/FingerprintEngineTest.php +330 -299
- package/src/php/Tests/ProblemManagerTest.php +376 -296
- package/src/php/Tests/RequestUtilsTest.php +386 -253
- package/src/php/Tests/problems.config.json +3 -3
- package/src/php/Utils/RequestUtils.php +201 -17
|
@@ -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
|
|
190
|
-
*/
|
|
191
|
-
public static function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
$
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
$
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
$
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
$
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
$
|
|
238
|
-
$
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
$
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
$
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
*
|
|
294
|
-
* @param
|
|
295
|
-
* @
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
$
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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>> " . ($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
|
}
|