@anonympins/fingerprint 0.3.2 → 0.3.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 +193 -0
- package/README.md +1080 -834
- package/composer.json +39 -0
- package/index.js +5 -0
- package/package.json +31 -23
- package/phpunit.xml +21 -0
- package/public/fp.js +2 -0
- package/src/js/build-client.js +69 -0
- package/{fingerprint.builder.js → src/js/fingerprint.builder.js} +168 -175
- package/{fingerprint.client.js → src/js/fingerprint.client.js} +634 -538
- package/src/js/fingerprint.client.obfuscated.js +1 -0
- package/{fingerprint.js → src/js/fingerprint.js} +3733 -3294
- package/{library.js → src/js/library.js} +1729 -1729
- package/{problem-manager.js → src/js/problem-manager.js} +539 -522
- package/src/php/AutoTuner.php +155 -0
- package/src/php/Challenge/ChallengeUtils.php +306 -0
- package/src/php/Config/SecurityProfiles.php +267 -0
- package/src/php/DirectFingerprint.php +81 -0
- package/src/php/FingerprintBuilder.php +186 -0
- package/src/php/FingerprintClient.php +132 -0
- package/src/php/FingerprintEngine.php +863 -0
- package/src/php/Optimization/FunctionRegistry.php +63 -0
- package/src/php/Optimization/Optimization.php +256 -0
- package/src/php/Optimization/OptimizationOperators.php +305 -0
- package/src/php/Optimization/ProblemInitializers.php +53 -0
- package/src/php/ProblemManager.php +255 -0
- package/src/php/RequestContext.php +91 -0
- package/src/php/Store/IStore.php +42 -0
- package/src/php/Store/InMemoryStore.php +67 -0
- package/src/php/Store/StoreManager.php +36 -0
- package/src/php/Tests/ChallengeUtilsTest.php +82 -0
- package/src/php/Tests/FingerprintBuilderTest.php +58 -0
- package/src/php/Tests/FingerprintEngineTest.php +300 -0
- package/src/php/Tests/IpReputationTest.php +157 -0
- package/src/php/Tests/PowTest.php +40 -0
- package/src/php/Tests/ProblemManagerTest.php +295 -0
- package/src/php/Tests/RequestUtilsTest.php +81 -0
- package/src/php/Tests/problems.config.json +9 -0
- package/src/php/Utils/BigInt.php +145 -0
- package/src/php/Utils/BlockList.php +100 -0
- package/src/php/Utils/Logger.php +30 -0
- package/src/php/Utils/MaliciousPatterns.php +59 -0
- package/src/php/Utils/RequestUtils.php +962 -0
- package/fingerprint.client.obfuscated.js +0 -1
- /package/{mongodb-store.js → src/js/mongodb-store.js} +0 -0
- /package/{optimization.worker.js → src/js/optimization.worker.js} +0 -0
- /package/{pow.solver.inline.js → src/js/pow.solver.inline.js} +0 -0
- /package/{pow.solver.js → src/js/pow.solver.js} +0 -0
- /package/{pow.worker.js → src/js/pow.worker.js} +0 -0
- /package/{redis-store.js → src/js/redis-store.js} +0 -0
- /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,91 @@
|
|
|
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 $ja4s;
|
|
38
|
+
public ?string $ja4h;
|
|
39
|
+
public ?string $http2Fingerprint;
|
|
40
|
+
public ?string $tcpFingerprint;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param string $clientIp
|
|
44
|
+
* @param string $path
|
|
45
|
+
* @param array<string, string> $headers
|
|
46
|
+
* @param array<string, mixed> $query
|
|
47
|
+
* @param array<string, mixed>|object|null $body
|
|
48
|
+
* @param array<string, string> $cookies
|
|
49
|
+
* @param string|null $httpVersion
|
|
50
|
+
* @param int|null $requestTimestamp
|
|
51
|
+
*/
|
|
52
|
+
public function __construct(
|
|
53
|
+
string $clientIp,
|
|
54
|
+
string $path,
|
|
55
|
+
array $headers,
|
|
56
|
+
array $query,
|
|
57
|
+
$body,
|
|
58
|
+
array $cookies,
|
|
59
|
+
?string $httpVersion,
|
|
60
|
+
?int $requestTimestamp = null
|
|
61
|
+
) {
|
|
62
|
+
$this->clientIp = $clientIp;
|
|
63
|
+
$this->path = $path;
|
|
64
|
+
// Normaliser les en-têtes en minuscules pour un accès cohérent
|
|
65
|
+
$this->headers = array_change_key_case($headers, CASE_LOWER);
|
|
66
|
+
$this->query = $query;
|
|
67
|
+
$this->body = $body;
|
|
68
|
+
$this->cookies = $cookies;
|
|
69
|
+
$this->httpVersion = $httpVersion;
|
|
70
|
+
$this->requestTimestamp = $requestTimestamp ?? (int)(microtime(true) * 1000);
|
|
71
|
+
|
|
72
|
+
// Extraire les empreintes TLS/HTTP2/TCP si elles sont fournies par les en-têtes
|
|
73
|
+
$this->ja3 = $this->headers['x-ja3-hash'] ?? null;
|
|
74
|
+
$this->ja4 = $this->headers['x-ja4-hash'] ?? null;
|
|
75
|
+
$this->ja4s = $this->headers['x-ja4s-hash'] ?? null;
|
|
76
|
+
$this->ja4h = $this->headers['x-ja4h-hash'] ?? null;
|
|
77
|
+
$this->http2Fingerprint = $this->headers['x-http2-fingerprint'] ?? null;
|
|
78
|
+
$this->tcpFingerprint = $this->headers['x-tcp-fingerprint'] ?? null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Récupère la valeur d'un en-tête HTTP de manière insensible à la casse.
|
|
82
|
+
*
|
|
83
|
+
* @param string $name Le nom de l'en-tête.
|
|
84
|
+
* @return string|null La valeur de l'en-tête ou null si non trouvé.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
public function getHeader(string $name): ?string
|
|
88
|
+
{
|
|
89
|
+
return $this->headers[strtolower($name)] ?? null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -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,36 @@
|
|
|
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
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Définit l'instance active du store (utile pour l'injection de dépendances et les tests).
|
|
29
|
+
*
|
|
30
|
+
* @param mixed $store
|
|
31
|
+
*/
|
|
32
|
+
public static function setStore($store): void
|
|
33
|
+
{
|
|
34
|
+
self::$store = $store;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -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
|
+
}
|