@anonympins/fingerprint 0.3.2 → 0.3.3

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 (50) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +276 -34
  3. package/composer.json +38 -0
  4. package/index.js +5 -0
  5. package/package.json +23 -18
  6. package/phpunit.xml +20 -0
  7. package/public/fp.js +2 -0
  8. package/src/js/build-client.js +69 -0
  9. package/{fingerprint.builder.js → src/js/fingerprint.builder.js} +168 -175
  10. package/{fingerprint.client.js → src/js/fingerprint.client.js} +634 -538
  11. package/src/js/fingerprint.client.obfuscated.js +1 -0
  12. package/{fingerprint.js → src/js/fingerprint.js} +255 -101
  13. package/{library.js → src/js/library.js} +1729 -1729
  14. package/{problem-manager.js → src/js/problem-manager.js} +539 -522
  15. package/src/php/AutoTuner.php +155 -0
  16. package/src/php/Challenge/ChallengeUtils.php +306 -0
  17. package/src/php/Config/SecurityProfiles.php +257 -0
  18. package/src/php/DirectFingerprint.php +81 -0
  19. package/src/php/FingerprintBuilder.php +185 -0
  20. package/src/php/FingerprintClient.php +118 -0
  21. package/src/php/FingerprintEngine.php +850 -0
  22. package/src/php/Optimization/FunctionRegistry.php +63 -0
  23. package/src/php/Optimization/Optimization.php +256 -0
  24. package/src/php/Optimization/OptimizationOperators.php +305 -0
  25. package/src/php/Optimization/ProblemInitializers.php +53 -0
  26. package/src/php/ProblemManager.php +255 -0
  27. package/src/php/RequestContext.php +87 -0
  28. package/src/php/Store/IStore.php +42 -0
  29. package/src/php/Store/InMemoryStore.php +67 -0
  30. package/src/php/Store/StoreManager.php +26 -0
  31. package/src/php/Tests/ChallengeUtilsTest.php +82 -0
  32. package/src/php/Tests/FingerprintBuilderTest.php +58 -0
  33. package/src/php/Tests/FingerprintEngineTest.php +219 -0
  34. package/src/php/Tests/PowTest.php +40 -0
  35. package/src/php/Tests/ProblemManagerTest.php +295 -0
  36. package/src/php/Tests/RequestUtilsTest.php +81 -0
  37. package/src/php/Tests/problems.config.json +9 -0
  38. package/src/php/Utils/BigInt.php +102 -0
  39. package/src/php/Utils/BlockList.php +100 -0
  40. package/src/php/Utils/Logger.php +30 -0
  41. package/src/php/Utils/MaliciousPatterns.php +59 -0
  42. package/src/php/Utils/RequestUtils.php +673 -0
  43. package/fingerprint.client.obfuscated.js +0 -1
  44. /package/{mongodb-store.js → src/js/mongodb-store.js} +0 -0
  45. /package/{optimization.worker.js → src/js/optimization.worker.js} +0 -0
  46. /package/{pow.solver.inline.js → src/js/pow.solver.inline.js} +0 -0
  47. /package/{pow.solver.js → src/js/pow.solver.js} +0 -0
  48. /package/{pow.worker.js → src/js/pow.worker.js} +0 -0
  49. /package/{redis-store.js → src/js/redis-store.js} +0 -0
  50. /package/{sql-store.js → src/js/sql-store.js} +0 -0
@@ -0,0 +1,255 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ use Anonympins\Fingerprint\Store\IStore;
8
+ use Anonympins\Fingerprint\Optimization\FunctionRegistry;
9
+ use Anonympins\Fingerprint\Optimization\ProblemInitializers;
10
+
11
+ class ProblemManager
12
+ {
13
+ private static ?ProblemManager $instance = null;
14
+ private string $configPath;
15
+ private IStore $store;
16
+ /** @var array<int, array<string, mixed>> */
17
+ private array $problems = [];
18
+ private int $currentProblemIndex = 0;
19
+ private bool $initialized = false;
20
+
21
+ /**
22
+ * Le constructeur est privé pour forcer l'utilisation du singleton.
23
+ */
24
+ private function __construct(string $configPath, IStore $store)
25
+ {
26
+ $this->configPath = $configPath;
27
+ $this->store = $store;
28
+ $this->loadProblems();
29
+ }
30
+
31
+ /**
32
+ * Obtient l'instance singleton du ProblemManager.
33
+ * Doit être initialisé une fois avec `init`.
34
+ */
35
+ public static function getInstance(?string $configPath = null, ?IStore $store = null): self
36
+ {
37
+ if (self::$instance === null) {
38
+ // Si on essaie d'obtenir l'instance sans l'initialiser d'abord, c'est une erreur.
39
+ if ($configPath === null || $store === null) {
40
+ throw new \RuntimeException("ProblemManager must be initialized with configPath and store.");
41
+ }
42
+ self::$instance = new self($configPath, $store);
43
+ }
44
+ return self::$instance;
45
+ }
46
+
47
+ public static function isInitialized(): bool
48
+ {
49
+ return self::$instance !== null && self::$instance->initialized;
50
+ }
51
+ /**
52
+ * Charge et parse les problèmes depuis le fichier de configuration.
53
+ */
54
+
55
+ private function loadProblems(): void
56
+ {
57
+ if (!file_exists($this->configPath)) {
58
+ error_log("[ProblemManager] Problem config file not found: {$this->configPath}");
59
+ return;
60
+ }
61
+ $data = file_get_contents($this->configPath);
62
+ if ($data === false) {
63
+ error_log("[ProblemManager] Failed to read problem config file: {$this->configPath}");
64
+ return;
65
+ }
66
+ $problemsFromFile = json_decode($data, true);
67
+ if (json_last_error() !== JSON_ERROR_NONE) {
68
+ error_log("[ProblemManager] Failed to parse problem config JSON: " . json_last_error_msg());
69
+ return;
70
+ }
71
+
72
+ foreach ($problemsFromFile as $problem) {
73
+ $storeKey = "problem-state:{$problem['id']}";
74
+ $storedState = $this->store->get($storeKey);
75
+
76
+ if ($storedState === null) {
77
+ $storedState = $problem['state'] ?? [];
78
+ $this->store->set($storeKey, $storedState); // Persist initial state
79
+ }
80
+ $problem['state'] = $storedState;
81
+
82
+ // Résolution dynamique des fonctions et des données
83
+ if (isset($problem['workUnit']['scoreFunction'])) {
84
+ $problem['workUnit']['scoreFunction'] = FunctionRegistry::get($problem['workUnit']['scoreFunction']);
85
+ if ($problem['workUnit']['scoreFunction'] === null) {
86
+ // @codeCoverageIgnoreStart
87
+ error_log("[ProblemManager] Warning: scoreFunction '{$problem['workUnit']['scoreFunction']}' not found in registry for problem '{$problem['id']}'.");
88
+ // @codeCoverageIgnoreEnd
89
+ }
90
+ }
91
+
92
+ if (isset($problem['payload']) && is_array($problem['payload'])) {
93
+ foreach ($problem['payload'] as $key => &$value) {
94
+ if (is_array($value) && isset($value['$init'])) {
95
+ $initializer = ProblemInitializers::get($value['$init']);
96
+ if ($initializer) {
97
+ $value = $initializer($value['params'] ?? []);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ $this->problems[] = $problem;
103
+ }
104
+ $this->initialized = true; // Marquer comme initialisé seulement après un chargement réussi
105
+ }
106
+
107
+ public function dispatchWork(float $suspicionFactor): ?array
108
+ {
109
+ if (empty($this->problems)) {
110
+ return null;
111
+ }
112
+
113
+ $problem = $this->problems[$this->currentProblemIndex];
114
+ $this->currentProblemIndex = ($this->currentProblemIndex + 1) % count($this->problems);
115
+
116
+ $task = ['type' => $problem['workUnit']['type']];
117
+ $scalingFactor = $problem['workUnit']['scalingFactor'] ?? null;
118
+
119
+ switch ($problem['workUnit']['type']) {
120
+ case 'simulated_annealing_iterations':
121
+ $baseIterations = $problem['workUnit']['baseIterations'] ?? 15000;
122
+ $task['iterations'] = $scalingFactor
123
+ ? (int)floor($baseIterations * pow($scalingFactor, $suspicionFactor))
124
+ : (int)floor($baseIterations * (0.5 + $suspicionFactor));
125
+ if (isset($problem['payload'])) {
126
+ $task['payload'] = $problem['payload'];
127
+ }
128
+ // Ensure payload is always an array to prevent errors when accessing it.
129
+ $task['payload'] = $task['payload'] ?? [];
130
+ $task['initialSolution'] = $problem['state']['bestSolution'] ?? null;
131
+ break;
132
+ case 'multi_objective_genetic_algorithm':
133
+ $baseGenerationsMulti = max(30, $problem['workUnit']['baseGenerations'] ?? 0);
134
+ $task['generations'] = $scalingFactor
135
+ ? (int)floor($baseGenerationsMulti * pow($scalingFactor, $suspicionFactor))
136
+ : (int)floor($baseGenerationsMulti * (0.5 + $suspicionFactor));
137
+ if (isset($problem['payload'])) {
138
+ $task['payload'] = $problem['payload'];
139
+ }
140
+ $task['initialFront'] = $problem['state']['paretoFront'] ?? null;
141
+ $task['solverName'] = $problem['workUnit']['solverName'];
142
+ break;
143
+ default:
144
+ error_log("[ProblemManager] Unknown useful work type: {$problem['workUnit']['type']}");
145
+ return null;
146
+ }
147
+
148
+ return ['problemId' => $problem['id'], 'task' => $task];
149
+ }
150
+
151
+ /**
152
+ * Intègre la solution d'un client dans l'état du problème.
153
+ *
154
+ * @param string $problemId L'ID du problème.
155
+ * @param array $solutionData La solution renvoyée par le client.
156
+ */
157
+ public function integrateSolution(string $problemId, array $solutionData): void
158
+ {
159
+ $problemIndex = array_search($problemId, array_column($this->problems, 'id'));
160
+ if ($problemIndex === false) {
161
+ return;
162
+ }
163
+ $problem = &$this->problems[$problemIndex]; // Use reference to modify in place
164
+
165
+ $stateChanged = false;
166
+
167
+ $storeKey = "problem-state:{$problem['id']}";
168
+
169
+ // La logique d'intégration dépend du type de problème.
170
+ switch ($problem['workUnit']['type']) {
171
+ case 'simulated_annealing_iterations':
172
+ if (isset($solutionData['solution']) && isset($solutionData['energy'])) {
173
+ $scoreFunction = $problem['workUnit']['scoreFunction'] ?? null;
174
+ if (!$scoreFunction) {
175
+ error_log("[ProblemManager] Aucune fonction de score définie pour {$problemId}.");
176
+ return;
177
+ }
178
+ // 1. Ne JAMAIS faire confiance au score du client. Recalculer systématiquement.
179
+ $recalculatedEnergy = $scoreFunction($solutionData['solution'], $problem['payload'] ?? []);
180
+
181
+ $currentBest = (float)($problem['state']['bestEnergy'] ?? INF);
182
+
183
+ // 2. Comparer le score recalculé, pas celui du client.
184
+ if ($recalculatedEnergy < $currentBest) { // @phpstan-ignore-line
185
+ $problem['state']['bestSolution'] = $solutionData['solution'];
186
+ $problem['state']['bestEnergy'] = $recalculatedEnergy; // 3. Stocker le score vérifié.
187
+ $problem['state']['lastUpdate'] = (new \DateTime())->format(\DateTime::ATOM);
188
+ $stateChanged = true;
189
+ error_log("[ProblemManager] New best solution for {$problemId}: {$recalculatedEnergy}"); // @phpstan-ignore-line
190
+ }
191
+ }
192
+ break;
193
+ case 'multi_objective_genetic_algorithm':
194
+ // Pour les algorithmes génétiques, on intègre le nouveau front de Pareto.
195
+ if (isset($solutionData['paretoFront']) && is_array($solutionData['paretoFront'])) {
196
+ $stateChanged = $this->_integrateParetoFront($problem, $solutionData['paretoFront']);
197
+ }
198
+ break;
199
+ default:
200
+ error_log("[ProblemManager] Integration not implemented for useful work type: {$problem['workUnit']['type']}");
201
+ break;
202
+ }
203
+ // Sauvegarder l'état mis à jour dans le store.
204
+ if ($stateChanged) {
205
+ $this->store->set($storeKey, $problem['state']);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Intègre un nouveau front de Pareto dans l'état du problème.
211
+ *
212
+ * @param array &$problem Le problème à mettre à jour (passé par référence).
213
+ * @param array $newFront Le nouveau front de Pareto soumis par le client.
214
+ */
215
+ private function _integrateParetoFront(array &$problem, array $newFront): bool
216
+ {
217
+ // Logique de fusion et de tri non-dominé pour mettre à jour le front de Pareto.
218
+ // Pour cet exemple, nous remplaçons simplement le front, mais une vraie implémentation
219
+ // fusionnerait les deux fronts et recalculerait le meilleur.
220
+ // On vérifie si le nouveau front est différent de l'actuel pour éviter des écritures inutiles.
221
+ $currentFront = $problem['state']['paretoFront'] ?? [];
222
+ if (!empty($newFront) && json_encode($newFront) !== json_encode($currentFront)) {
223
+ $problem['state']['paretoFront'] = $newFront;
224
+ $problem['state']['lastUpdate'] = (new \DateTime())->format(\DateTime::ATOM);
225
+ error_log("[ProblemManager] New Pareto front for {$problem['id']} with " . count($newFront) . " solutions."); // @phpstan-ignore-line
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ /**
232
+ * Réinitialise l'instance singleton.
233
+ * @internal Uniquement pour les tests.
234
+ */
235
+ public static function resetInstanceForTests(): void
236
+ {
237
+ self::$instance = null;
238
+ }
239
+
240
+ /**
241
+ * Réinitialise l'instance singleton.
242
+ * @internal Uniquement pour les tests.
243
+ */
244
+ public static function __internal_resetInstance(): void {
245
+ self::$instance = null;
246
+ }
247
+
248
+ /**
249
+ * @internal For testing purposes only.
250
+ */
251
+ public function getProblems(): array
252
+ {
253
+ return $this->problems;
254
+ }
255
+ }
@@ -0,0 +1,87 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ /**
8
+ * Représente le contexte d'une requête HTTP, fournissant un accès unifié
9
+ * aux informations nécessaires pour l'analyse de l'empreinte.
10
+ * Cette classe est conçue pour être créée à partir d'un objet de requête
11
+ * standard (ex: PSR-7, Symfony, Laravel).
12
+ */
13
+ class RequestContext
14
+ {
15
+ public string $clientIp;
16
+ public string $path;
17
+ /** @var array<string, string> */
18
+ public array $headers;
19
+ /** @var array<string, mixed> */
20
+ public array $query;
21
+ /** @var array<string, mixed>|object|null */
22
+ public $body;
23
+ /** @var array<string, string> */
24
+ public array $cookies;
25
+ public ?string $httpVersion;
26
+ public int $requestTimestamp;
27
+
28
+ /** @var ?array{type: string, name: string} */
29
+ public ?array $graphqlOperation = null;
30
+
31
+ /** @var ?array<string, mixed> */
32
+ public ?array $newCookieForResponse = null;
33
+
34
+ // Propriétés spécifiques qui peuvent être fournies par un proxy inverse
35
+ public ?string $ja3;
36
+ public ?string $ja4;
37
+ public ?string $http2Fingerprint;
38
+ public ?string $tcpFingerprint;
39
+
40
+ /**
41
+ * @param string $clientIp
42
+ * @param string $path
43
+ * @param array<string, string> $headers
44
+ * @param array<string, mixed> $query
45
+ * @param array<string, mixed>|object|null $body
46
+ * @param array<string, string> $cookies
47
+ * @param string|null $httpVersion
48
+ * @param int|null $requestTimestamp
49
+ */
50
+ public function __construct(
51
+ string $clientIp,
52
+ string $path,
53
+ array $headers,
54
+ array $query,
55
+ $body,
56
+ array $cookies,
57
+ ?string $httpVersion,
58
+ ?int $requestTimestamp = null
59
+ ) {
60
+ $this->clientIp = $clientIp;
61
+ $this->path = $path;
62
+ // Normaliser les en-têtes en minuscules pour un accès cohérent
63
+ $this->headers = array_change_key_case($headers, CASE_LOWER);
64
+ $this->query = $query;
65
+ $this->body = $body;
66
+ $this->cookies = $cookies;
67
+ $this->httpVersion = $httpVersion;
68
+ $this->requestTimestamp = $requestTimestamp ?? (int)(microtime(true) * 1000);
69
+
70
+ // Extraire les empreintes TLS/HTTP2/TCP si elles sont fournies par les en-têtes
71
+ $this->ja3 = $this->headers['x-ja3-hash'] ?? null;
72
+ $this->ja4 = $this->headers['x-ja4-hash'] ?? null;
73
+ $this->http2Fingerprint = $this->headers['x-http2-fingerprint'] ?? null;
74
+ $this->tcpFingerprint = $this->headers['x-tcp-fingerprint'] ?? null;
75
+ }
76
+ /**
77
+ * Récupère la valeur d'un en-tête HTTP de manière insensible à la casse.
78
+ *
79
+ * @param string $name Le nom de l'en-tête.
80
+ * @return string|null La valeur de l'en-tête ou null si non trouvé.
81
+ */
82
+
83
+ public function getHeader(string $name): ?string
84
+ {
85
+ return $this->headers[strtolower($name)] ?? null;
86
+ }
87
+ }
@@ -0,0 +1,42 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Store;
6
+
7
+ /**
8
+ * Interface pour un système de stockage persistant.
9
+ * Utilisé pour stocker les données des appareils, les secrets des challenges, etc.
10
+ */
11
+ interface IStore
12
+ {
13
+ /**
14
+ * Récupère une valeur associée à une clé.
15
+ * @param string $key
16
+ * @return mixed|null
17
+ */
18
+ public function get(string $key);
19
+
20
+ /**
21
+ * Stocke une valeur associée à une clé, avec une durée de vie optionnelle.
22
+ * @param string $key
23
+ * @param mixed $value
24
+ * @param int|null $ttl Durée de vie en secondes.
25
+ * @return void
26
+ */
27
+ public function set(string $key, $value, ?int $ttl = null): void;
28
+
29
+ /**
30
+ * Vérifie si une clé existe dans le stockage.
31
+ * @param string $key
32
+ * @return bool
33
+ */
34
+ public function has(string $key): bool;
35
+
36
+ /**
37
+ * Supprime une clé du stockage.
38
+ * @param string $key
39
+ * @return void
40
+ */
41
+ public function delete(string $key): void;
42
+ }
@@ -0,0 +1,67 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Store;
6
+
7
+ /**
8
+ * Implémentation en mémoire de l'interface IStore.
9
+ * Utile pour le développement ou les applications à instance unique sans persistance externe.
10
+ */
11
+ class InMemoryStore implements IStore
12
+ {
13
+ /**
14
+ * @var array<string, array{value: mixed, expiresAt: int|null}>
15
+ */
16
+ private array $data = [];
17
+
18
+ public function get(string $key)
19
+ {
20
+ if (!isset($this->data[$key])) {
21
+ return null;
22
+ }
23
+
24
+ $item = $this->data[$key];
25
+ if ($item['expiresAt'] !== null && $item['expiresAt'] < time()) {
26
+ $this->delete($key); // Supprime l'élément expiré
27
+ return null;
28
+ }
29
+
30
+ return $item['value'];
31
+ }
32
+
33
+ public function set(string $key, $value, ?int $ttl = null): void
34
+ {
35
+ $expiresAt = $ttl !== null ? time() + $ttl : null;
36
+
37
+ $this->data[$key] = ['value' => $value, 'expiresAt' => $expiresAt];
38
+ }
39
+
40
+ public function has(string $key): bool
41
+ {
42
+ if (!isset($this->data[$key])) {
43
+ return false;
44
+ }
45
+
46
+ $item = $this->data[$key];
47
+ if ($item['expiresAt'] !== null && $item['expiresAt'] < time()) {
48
+ $this->delete($key);
49
+ return false;
50
+ }
51
+
52
+ return true;
53
+ }
54
+
55
+ public function delete(string $key): void
56
+ {
57
+ unset($this->data[$key]);
58
+ }
59
+
60
+ /**
61
+ * Efface toutes les données du store. Utile pour les tests.
62
+ */
63
+ public function clear(): void
64
+ {
65
+ $this->data = [];
66
+ }
67
+ }
@@ -0,0 +1,26 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Store;
6
+
7
+ /**
8
+ * Gère l'instance du store global.
9
+ */
10
+ class StoreManager
11
+ {
12
+ private static ?IStore $store = null;
13
+
14
+ public static function getStore(): IStore
15
+ {
16
+ if (self::$store === null) {
17
+ self::$store = new InMemoryStore();
18
+ }
19
+ return self::$store;
20
+ }
21
+
22
+ public static function configureStore(IStore $externalStore): void
23
+ {
24
+ self::$store = $externalStore;
25
+ }
26
+ }
@@ -0,0 +1,82 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
9
+ use Anonympins\Fingerprint\Utils\BigInt;
10
+
11
+ class ChallengeUtilsTest extends TestCase
12
+ {
13
+ protected function setUp(): void
14
+ {
15
+ // Définir une clé secrète pour les tests
16
+ $_ENV['POW_SECRET'] = 'test-secret-key-that-is-long-enough-for-hmac';
17
+ }
18
+
19
+ public function testIsTicketValid(): void
20
+ {
21
+ $ip = '127.0.0.1';
22
+ $expiry = (int)floor(microtime(true) * 1000) + 3600000; // 1 heure
23
+ $signature = hash_hmac('sha256', "{$ip}:{$expiry}", $_ENV['POW_SECRET']);
24
+ $validTicket = "{$expiry}:{$signature}";
25
+
26
+ $this->assertTrue(ChallengeUtils::isTicketValid($ip, $validTicket));
27
+ $this->assertFalse(ChallengeUtils::isTicketValid('192.168.1.1', $validTicket), "Le ticket ne doit pas être valide pour une autre IP.");
28
+
29
+ $expiredExpiry = (int)floor(microtime(true) * 1000) - 1000;
30
+ $expiredSignature = hash_hmac('sha256', "{$ip}:{$expiredExpiry}", $_ENV['POW_SECRET']);
31
+ $expiredTicket = "{$expiredExpiry}:{$expiredSignature}";
32
+ $this->assertFalse(ChallengeUtils::isTicketValid($ip, $expiredTicket), "Un ticket expiré doit être invalide.");
33
+
34
+ $this->assertFalse(ChallengeUtils::isTicketValid($ip, 'invalid-ticket-format'), "Un format de ticket invalide doit être rejeté.");
35
+ }
36
+
37
+ public function testCpuTargetCalculation(): void
38
+ {
39
+ $config = ['cpu' => ['minDifficultyBits' => 8, 'maxDifficultyBits' => 24]];
40
+
41
+ // Low suspicion -> minimum difficulty
42
+ $targetLow = ChallengeUtils::calculateCpuTarget(0.0, $config);
43
+ $expectedTargetLow = (new BigInt(1))->shiftLeft(256 - 8);
44
+ $this->assertEquals(0, BigInt::fromHex($targetLow)->compareTo($expectedTargetLow), "Target for 0.0 suspicion should correspond to 8 bits of difficulty.");
45
+
46
+ // High suspicion -> maximum difficulty
47
+ $targetHigh = ChallengeUtils::calculateCpuTarget(1.0, $config);
48
+ $expectedTargetHigh = (new BigInt(1))->shiftLeft(256 - 24);
49
+ $this->assertEquals(0, BigInt::fromHex($targetHigh)->compareTo($expectedTargetHigh), "Target for 1.0 suspicion should correspond to 24 bits of difficulty.");
50
+
51
+ // Medium suspicion -> intermediate difficulty
52
+ $targetMid = ChallengeUtils::calculateCpuTarget(0.5, $config);
53
+ $expectedBits = 8 + 0.5 * (24 - 8); // 16 bits
54
+ $expectedTargetMid = (new BigInt(1))->shiftLeft(256 - (int)$expectedBits);
55
+ $this->assertEquals(0, BigInt::fromHex($targetMid)->compareTo($expectedTargetMid), "Target for 0.5 suspicion should correspond to 16 bits of difficulty.");
56
+ }
57
+
58
+ public function testVerifyCpuTargetPoW(): void
59
+ {
60
+ $ip = '127.0.0.1';
61
+ $nonce = 'test-nonce';
62
+ $fingerprint = 'ua:test-fp';
63
+ $clientSecret = 'test-secret';
64
+ $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $fingerprint);
65
+
66
+ // Cible facile pour un test rapide (ex: 4 zéros hexadécimaux -> 16 bits)
67
+ $targetHex = '0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
68
+ $challengeContext = ['cpuTarget' => $targetHex, 'baseBlock' => $baseBlock];
69
+
70
+ // Trouver une solution valide
71
+ $solution = 0;
72
+ while (true) {
73
+ $hash = hash('sha256', $baseBlock . $solution);
74
+ if (strcmp($hash, $targetHex) < 0) break;
75
+ $solution++;
76
+ }
77
+
78
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($ip, 3600, $nonce, (string)$solution, $challengeContext);
79
+ $this->assertNotNull($ticket, "Un ticket valide aurait dû être généré.");
80
+ $this->assertTrue(ChallengeUtils::isTicketValid($ip, $ticket));
81
+ }
82
+ }
@@ -0,0 +1,58 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\FingerprintBuilder;
9
+
10
+ class FingerprintBuilderTest extends TestCase
11
+ {
12
+ public function testCyrb53IsDeterministic(): void
13
+ {
14
+ $input = "test-string";
15
+ // Note: The PHP and JS implementations of cyrb53 produce different hash strings
16
+ // due to differences in large number handling, but they are internally consistent.
17
+ $this->assertEquals(FingerprintBuilder::cyrb53($input), FingerprintBuilder::cyrb53($input));
18
+ $this->assertNotEquals(FingerprintBuilder::cyrb53("a"), FingerprintBuilder::cyrb53("b"));
19
+ }
20
+
21
+ public function testBuilderHandlesNullAndEmptyValues(): void
22
+ {
23
+ $builder = new FingerprintBuilder();
24
+ $builder->add('key1', 'value1');
25
+ $builder->add('key2', null);
26
+ $builder->add('key3', '');
27
+
28
+ // The JS equivalent for this hash is '6263243896157005'
29
+ $this->assertEquals('key1:6263243896157005', (string)$builder);
30
+ }
31
+
32
+ public function testToStringSortsKeysDeterministically(): void
33
+ {
34
+ $builder1 = new FingerprintBuilder();
35
+ $builder1->add('b', '2')->add('a', '1');
36
+
37
+ $builder2 = new FingerprintBuilder();
38
+ $builder2->add('a', '1')->add('b', '2');
39
+
40
+ $this->assertEquals((string)$builder1, (string)$builder2);
41
+ }
42
+
43
+ public function testCompareLogic(): void
44
+ {
45
+ $fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString();
46
+ $fp2 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString();
47
+ $fp3 = (new FingerprintBuilder())->add('hw', '4_8')->add('gpu', 'amd')->__toString();
48
+ $fp4 = (new FingerprintBuilder())->add('hw', '8_16')->add('os', 'win32')->__toString(); // Partial match
49
+
50
+ $this->assertEquals(1.0, FingerprintBuilder::compare($fp1, $fp2), "Identical FPs should return 1.0");
51
+ $this->assertEquals(0.0, FingerprintBuilder::compare($fp1, $fp3), "Completely different FPs should have a score of 0");
52
+
53
+ $this->assertEqualsWithDelta(0.25, FingerprintBuilder::compare($fp1, $fp4), 0.2, "Partial match should have a specific score");
54
+
55
+ $this->assertEquals(0.0, FingerprintBuilder::compare($fp1, ''), "Comparison with empty string should be 0.0");
56
+ $this->assertEquals(0.0, FingerprintBuilder::compare(null, $fp2), "Comparison with null should be 0.0");
57
+ }
58
+ }