@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,155 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ use Anonympins\Fingerprint\Optimization\OptimizationOperators;
8
+
9
+ /**
10
+ * Gère le processus d'auto-ajustement en arrière-plan pour les seuils et poids de sécurité.
11
+ * Conçu pour être exécuté périodiquement (par exemple, via une tâche cron).
12
+ */
13
+ class AutoTuner
14
+ {
15
+ /**
16
+ * @var array<string, mixed> La configuration de sécurité en direct qui sera mutée.
17
+ */
18
+ private array $securityConfig;
19
+
20
+ /**
21
+ * @var array<int, array<string, mixed>> Les données de trafic collectées.
22
+ */
23
+ private array $trafficData;
24
+
25
+ private int $minDataPoints;
26
+ private int $maxDataPoints;
27
+
28
+ /**
29
+ * @var ?array<string, mixed> La dernière meilleure solution trouvée par l'optimiseur.
30
+ */
31
+ private static ?array $lastBestSolution = null;
32
+
33
+ /**
34
+ * @param array<string, mixed> &$securityConfig La configuration de sécurité (passée par référence).
35
+ * @param array<int, array<string, mixed>> &$trafficData Les données de trafic (passées par référence).
36
+ * @param array<string, int> $options Options pour l'auto-ajustement.
37
+ */
38
+ public function __construct(array &$securityConfig, array &$trafficData, array $options = [])
39
+ {
40
+ $this->securityConfig = &$securityConfig;
41
+ $this->trafficData = &$trafficData;
42
+ $this->minDataPoints = $options['minDataPoints'] ?? 200;
43
+ $this->maxDataPoints = $options['maxDataPoints'] ?? 10000;
44
+ }
45
+
46
+ /**
47
+ * Exécute un cycle d'optimisation des seuils.
48
+ */
49
+ public function runOptimizationCycle(): void
50
+ {
51
+ $highConfidenceLogs = count(array_filter(
52
+ $this->trafficData,
53
+ fn ($log) => in_array($log['type'], ['challenge_solved', 'trap_triggered'])
54
+ ));
55
+ $highConfidenceRatio = count($this->trafficData) > 0 ? $highConfidenceLogs / count($this->trafficData) : 0;
56
+ $minConfidenceRatio = 0.05; // Exiger au moins 5% de signaux forts.
57
+
58
+ if (count($this->trafficData) < $this->minDataPoints || $highConfidenceRatio < $minConfidenceRatio) {
59
+ if (count($this->trafficData) < $this->minDataPoints) {
60
+ echo sprintf("[AutoTuning] Reporté : %d/%d points de données.\n", count($this->trafficData), $this->minDataPoints);
61
+ } else {
62
+ echo sprintf("[AutoTuning] Reporté : Ratio de confiance insuffisant (%.2f%% < %.2f%%).\n", $highConfidenceRatio * 100, $minConfidenceRatio * 100);
63
+ }
64
+ return;
65
+ }
66
+
67
+ if (count($this->trafficData) > $this->maxDataPoints) {
68
+ echo sprintf("[AutoTuning] Le journal de trafic a atteint %d entrées (max: %d). Troncation des données les plus anciennes.\n", count($this->trafficData), $this->maxDataPoints);
69
+ $this->trafficData = array_slice($this->trafficData, count($this->trafficData) - $this->maxDataPoints);
70
+ }
71
+
72
+ echo sprintf("[AutoTuning] Démarrage du cycle d'optimisation avec %d points de données.\n", count($this->trafficData));
73
+
74
+ $paretoFront = OptimizationOperators::solveFullSecurityTuning(['trafficData' => $this->trafficData]);
75
+
76
+ if (empty($paretoFront)) {
77
+ echo "[AutoTuning] L'optimisation n'a retourné aucune solution.\n";
78
+ return;
79
+ }
80
+
81
+ // Stratégie de sélection : choisir la solution la plus équilibrée (la plus proche de l'origine).
82
+ $bestSolution = $paretoFront[0];
83
+ $minDistance = sqrt(pow($bestSolution['objectives'][0], 2) + pow($bestSolution['objectives'][1], 2));
84
+
85
+ for ($i = 1; $i < count($paretoFront); $i++) {
86
+ $distance = sqrt(pow($paretoFront[$i]['objectives'][0], 2) + pow($paretoFront[$i]['objectives'][1], 2));
87
+ if ($distance < $minDistance) {
88
+ $minDistance = $distance;
89
+ $bestSolution = $paretoFront[$i];
90
+ }
91
+ }
92
+
93
+ // Logique d'inertie pour l'application de la configuration.
94
+ $newConfig = $bestSolution['solution'];
95
+ $maxChangeVelocity = 0.15; // 15% de changement maximum par cycle.
96
+
97
+ $applyInertialUpdate = function (&$currentConfig, $targetConfig) use ($maxChangeVelocity) {
98
+ if (empty($currentConfig) || empty($targetConfig)) return;
99
+
100
+ $totalCurrentWeight = 0;
101
+ $totalTargetWeight = 0;
102
+
103
+ foreach ($currentConfig as $key => $value) {
104
+ if (isset($targetConfig[$key])) {
105
+ $totalCurrentWeight += $value;
106
+ $totalTargetWeight += $targetConfig[$key];
107
+ }
108
+ }
109
+
110
+ if ($totalCurrentWeight === 0) return;
111
+
112
+ $globalChangeRatio = ($totalTargetWeight - $totalCurrentWeight) / $totalCurrentWeight;
113
+ $adjustmentFactor = max(-$maxChangeVelocity, min($maxChangeVelocity, $globalChangeRatio));
114
+
115
+ foreach ($currentConfig as $key => &$value) {
116
+ if (isset($targetConfig[$key])) {
117
+ $value *= (1 + $adjustmentFactor);
118
+ }
119
+ }
120
+ };
121
+
122
+ $applyInertialUpdate($this->securityConfig['thresholds'], $newConfig['thresholds']);
123
+ $applyInertialUpdate($this->securityConfig['weights'], $newConfig['weights']);
124
+ $applyInertialUpdate($this->securityConfig['patterns'], $newConfig['patterns']);
125
+
126
+ self::$lastBestSolution = $bestSolution;
127
+
128
+ echo "[AutoTuning] Nouvelle configuration de sécurité optimisée appliquée.\n";
129
+ echo "[AutoTuning] Objectifs atteints : " . json_encode([
130
+ 'falsePositiveRate' => round($bestSolution['objectives'][0], 4),
131
+ 'falseNegativeRate' => round($bestSolution['objectives'][1], 4)
132
+ ]) . "\n";
133
+ echo "[AutoTuning] Nouveaux seuils : " . json_encode($this->securityConfig['thresholds']) . "\n";
134
+ echo "[AutoTuning] Nouveaux poids : " . json_encode($this->securityConfig['weights']) . "\n";
135
+ echo "[AutoTuning] Nouveaux patterns : " . json_encode($this->securityConfig['patterns']) . "\n";
136
+ }
137
+
138
+ /**
139
+ * Retourne la dernière meilleure solution trouvée par l'auto-tuner.
140
+ * @return array<string, mixed>|null
141
+ */
142
+ public static function getBestTuningSolution(): ?array
143
+ {
144
+ return self::$lastBestSolution;
145
+ }
146
+
147
+ /**
148
+ * Réinitialise la meilleure solution statique. Utile pour les tests.
149
+ * @internal
150
+ */
151
+ public static function resetBestTuningSolution(): void
152
+ {
153
+ self::$lastBestSolution = null;
154
+ }
155
+ }
@@ -0,0 +1,306 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Challenge;
6
+
7
+ use Anonympins\Fingerprint\Utils\BigInt;
8
+
9
+ /**
10
+ * Classe utilitaire pour la génération et la vérification des challenges Proof-of-Work.
11
+ */
12
+ class ChallengeUtils
13
+ {
14
+ private const TRAP_URL_TEMPLATES = [
15
+ '/includes/config-{RANDOM}.php',
16
+ '/.env.{RANDOM}',
17
+ '/backups/db_backup_{RANDOM}.sql.gz',
18
+ '/api/v1/internal/status?trace={RANDOM}',
19
+ '/_private/deploy_key_{RANDOM}.pem',
20
+ '/logs/app_error_{RANDOM}.log',
21
+ '/.git/config_{RANDOM}'
22
+ ];
23
+
24
+ /**
25
+ * Récupère la clé secrète pour les PoW depuis les variables d'environnement.
26
+ */
27
+ private static function getPowSecret(): string
28
+ {
29
+ $secret = $_ENV['POW_SECRET'] ?? getenv('POW_SECRET');
30
+ if (!$secret && ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production') {
31
+ throw new \RuntimeException('POW_SECRET environment variable is not set. This is required for production.');
32
+ }
33
+ return $secret ?: "fallback-dev-secret-32-chars-minimum";
34
+ }
35
+
36
+ /**
37
+ * Vérifie si un ticket de passage est valide.
38
+ */
39
+ public static function isTicketValid(?string $ip, ?string $ticket): bool
40
+ {
41
+ if (empty($ip) || empty($ticket) || !str_contains($ticket, ':')) {
42
+ return false;
43
+ }
44
+
45
+ [$expiry, $sig] = explode(':', $ticket, 2);
46
+ if (empty($expiry) || empty($sig) || (int)floor((float)$expiry) < (int)floor(microtime(true) * 1000)) {
47
+ return false;
48
+ }
49
+
50
+ $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", self::getPowSecret());
51
+
52
+ return hash_equals($expectedSig, $sig);
53
+ }
54
+
55
+ /**
56
+ * Calcule la cible de difficulté pour un challenge CPU en fonction du facteur de suspicion.
57
+ */
58
+ public static function calculateCpuTarget(float $suspicionFactor, array $securityConfig): string
59
+ {
60
+ $cpuConfig = $securityConfig['cpu'] ?? [];
61
+ $minDifficultyBits = $cpuConfig['minDifficultyBits'] ?? 8;
62
+ $maxDifficultyBits = $cpuConfig['maxDifficultyBits'] ?? 24;
63
+
64
+ $totalDifficultyBits = $minDifficultyBits + $suspicionFactor * ($maxDifficultyBits - $minDifficultyBits);
65
+
66
+ if ($totalDifficultyBits <= 0) {
67
+ // Cible maximale (challenge trivial)
68
+ return (BigInt::pow(2, 256)->sub(new BigInt(1)))->toHex();
69
+ }
70
+
71
+ $shift = 256 - (int)floor($totalDifficultyBits);
72
+ return (new BigInt(1))->shiftLeft($shift)->toHex();
73
+ }
74
+
75
+ /**
76
+ * Crée le bloc de données de base pour le challenge CPU.
77
+ */
78
+ public static function createCpuChallengeBaseBlock(string $nonce, string $clientSecret, string $fingerprint): string
79
+ {
80
+ $parts = explode('|', $fingerprint);
81
+ $filteredParts = array_filter($parts);
82
+ sort($filteredParts);
83
+ $sortedFingerprint = implode('|', $filteredParts);
84
+
85
+ return "{$nonce}:{$clientSecret}:{$sortedFingerprint}:";
86
+ }
87
+
88
+ /**
89
+ * Vérifie une solution de PoW CPU et génère un ticket si elle est valide.
90
+ * @return string|null Le ticket en cas de succès, sinon null.
91
+ */
92
+ public static function verifyCpuTargetPoWAndGenerateTicket(
93
+ string $clientIp,
94
+ int $ticketTtl,
95
+ string $nonce,
96
+ string $solution,
97
+ array $challengeContext
98
+ ): ?string {
99
+ $cpuTargetHex = $challengeContext['cpuTarget'] ?? null;
100
+ $baseBlock = $challengeContext['baseBlock'] ?? null;
101
+
102
+ if ($cpuTargetHex === null || $baseBlock === null) {
103
+ error_log('[FP Server Verify] Invalid challenge context. Missing cpuTarget or baseBlock.');
104
+ return null;
105
+ }
106
+
107
+ $finalBlock = $baseBlock . $solution;
108
+ $hash = hash('sha256', $finalBlock);
109
+
110
+ $hashAsInt = BigInt::fromHex($hash);
111
+ $targetAsInt = BigInt::fromHex($cpuTargetHex);
112
+
113
+ $isValid = $hashAsInt->compareTo($targetAsInt) < 0;
114
+
115
+ if ($isValid) {
116
+ error_log('[FP Server Verify] CPU PoW verification PASSED.');
117
+ $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
118
+ $signature = hash_hmac('sha256', "{$clientIp}:{$expiry}", self::getPowSecret());
119
+ return "{$expiry}:{$signature}";
120
+ }
121
+
122
+ // Log details on failure
123
+ error_log(sprintf(
124
+ '[FP Server Verify] CPU PoW verification FAILED. Details: hashCalculated=0x%s, target=0x%s',
125
+ $hash,
126
+ $cpuTargetHex
127
+ ));
128
+
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Vérifie une solution de PoW mémoire.
134
+ */
135
+ public static function verifyMemoryPoW(
136
+ string $nonce,
137
+ string $solution,
138
+ int $difficulty,
139
+ string $clientSecret
140
+ ): bool {
141
+ $maxAllowedMemDifficulty = 128; // 128MB
142
+ if ($difficulty > $maxAllowedMemDifficulty) {
143
+ error_log("[Security] Memory PoW verification attempt with excessive difficulty: {$difficulty}MB. Denied.");
144
+ return false;
145
+ }
146
+
147
+ $size = $difficulty * 1024 * 1024;
148
+ if ($size <= 0) {
149
+ return true; // Pas de challenge mémoire si la difficulté est nulle ou négative.
150
+ }
151
+ $iterations = (int)floor($size / 16);
152
+ $buffer = new \SplFixedArray((int)floor($size / 4));
153
+
154
+ $seed = ":{$nonce}:{$clientSecret}";
155
+ $h = 0;
156
+ foreach (unpack('C*', $seed) as $byte) {
157
+ $h += $byte;
158
+ }
159
+
160
+ for ($i = 0; $i < count($buffer); $i++) {
161
+ $buffer[$i] = $h = self::gmp_imul($h ^ $i, 1597334677);
162
+ }
163
+
164
+ $finalHash = 0;
165
+ $addr = count($buffer) > 0 ? $buffer[0] % count($buffer) : 0;
166
+ for ($i = 0; $i < $iterations; $i++) {
167
+ $addr = $buffer[$addr] % count($buffer);
168
+ $finalHash ^= $addr;
169
+ }
170
+
171
+ return $finalHash === (int)$solution;
172
+ }
173
+
174
+ /**
175
+ * Émule la multiplication 32-bit `Math.imul` de JavaScript.
176
+ */
177
+ private static function gmp_imul(int $a, int $b): int
178
+ {
179
+ $a_lo = $a & 0xffff;
180
+ $a_hi = $a >> 16;
181
+ $b_lo = $b & 0xffff;
182
+ $b_hi = $b >> 16;
183
+ return (($a_lo * $b_lo) + ((($a_hi * $b_lo + $a_lo * $b_hi) << 16) & 0xffffffff)) | 0;
184
+ }
185
+
186
+ /**
187
+ * Génère une URL piège signée.
188
+ * @param string $nonce Le nonce pour signer l'URL.
189
+ * @return string L'URL piège.
190
+ */
191
+ public static function generateTrapUrl(string $nonce): string
192
+ {
193
+ $template = self::TRAP_URL_TEMPLATES[array_rand(self::TRAP_URL_TEMPLATES)];
194
+ $randomPart = bin2hex(random_bytes(8));
195
+ $path = str_replace('{RANDOM}', $randomPart, $template);
196
+
197
+ $signature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
198
+ return "{$path}?sig={$signature}";
199
+ }
200
+
201
+ /**
202
+ * Vérifie si une URL donnée est une URL piège valide pour un nonce donné.
203
+ * @param string $path Le chemin de la requête.
204
+ * @param string $signature La signature provenant de la query string.
205
+ * @param string $nonce Le nonce à vérifier.
206
+ * @return bool
207
+ */
208
+ public static function verifyTrapUrl(string $path, string $signature, string $nonce): bool
209
+ {
210
+ if (empty($signature)) {
211
+ return false;
212
+ }
213
+ $expectedSignature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
214
+ // Utilise hash_equals pour une comparaison sécurisée contre les attaques temporelles.
215
+ return hash_equals($expectedSignature, $signature);
216
+ }
217
+
218
+ /**
219
+ * Charge le contenu du solveur JS pour l'injection inline.
220
+ * @return string Le code JavaScript du solveur.
221
+ */
222
+ private static function getPowSolverCode(): string
223
+ {
224
+ // Le chemin doit être relatif à ce fichier ou absolu.
225
+ $solverPath = __DIR__ . '/../../js/pow.solver.inline.js';
226
+ if (!file_exists($solverPath)) {
227
+ error_log("[ChallengeUtils] Erreur: Le fichier pow.solver.inline.js n'a pas été trouvé à l'emplacement attendu.");
228
+ return '';
229
+ }
230
+ return file_get_contents($solverPath) ?: '';
231
+ }
232
+
233
+ /**
234
+ * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
235
+ * @param array $cpuChallengeDetails
236
+ * @param int $memoryDifficulty
237
+ * @param string $clientSecret
238
+ * @param array $securityConfig
239
+ * @param array $trapUrls
240
+ * @param string $originalFingerprint
241
+ * @return string
242
+ */
243
+ public static function generateCombinedPoWChallengePage(
244
+ array $cpuChallengeDetails,
245
+ int $memoryDifficulty,
246
+ string $clientSecret,
247
+ array $securityConfig,
248
+ array $trapUrls,
249
+ string $originalFingerprint
250
+ ): string {
251
+ $nonce = $cpuChallengeDetails['nonce'];
252
+ $target = $cpuChallengeDetails['target']; // @phpstan-ignore-line
253
+ $path = $cpuChallengeDetails['path'];
254
+
255
+ $solverCode = self::getPowSolverCode();
256
+ $baseBlock = self::createCpuChallengeBaseBlock($nonce, $clientSecret, $originalFingerprint);
257
+ $baseBlockBytes = '[' . implode(',', array_values(unpack('C*', $baseBlock))) . ']';
258
+
259
+ $trapLinksHtml = implode(' ', array_map(fn($url) => "<a href=\"{$url}\" tabindex=\"-1\">config</a>", $trapUrls));
260
+ $trapContainerHtml = "<div style=\"position:absolute;left:-9999px;top:-9999px;\" aria-hidden=\"true\">{$trapLinksHtml}</div>";
261
+
262
+ $challengeScript = <<<JS
263
+ async function solve() {
264
+ const nonce = "{$nonce}";
265
+ const path = "{$path}";
266
+ const clientSecret = "{$clientSecret}";
267
+ const cpuTarget = BigInt("0x" + "{$target}");
268
+ const memDifficulty = {$memoryDifficulty};
269
+ const baseBlock = new Uint8Array({$baseBlockBytes});
270
+
271
+ document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
272
+ const cpuSolution = await window.solveCpuChallengeInline(baseBlock, cpuTarget, (progress) => {});
273
+
274
+ if (memDifficulty > 0) {
275
+ document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (' + memDifficulty + ' MB)';
276
+ await new Promise(r => setTimeout(r, 10));
277
+ }
278
+ let memSolution = 0;
279
+ try {
280
+ const memSeed = nonce + ":" + clientSecret;
281
+ memSolution = await window.solveMemoryChallenge(memSeed, memDifficulty);
282
+ } catch(e) {
283
+ document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
284
+ return;
285
+ }
286
+
287
+ const finalUrl = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
288
+ window.location.href = finalUrl;
289
+ }
290
+ solve();
291
+ JS;
292
+
293
+ $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>';
294
+ $customTemplatePath = $securityConfig['challengePagePath'] ?? null;
295
+
296
+ if ($customTemplatePath && file_exists($customTemplatePath)) {
297
+ $htmlTemplate = file_get_contents($customTemplatePath) ?: $htmlTemplate;
298
+ }
299
+
300
+ return str_replace(
301
+ ['<!-- FINGERPRINT_SOLVER_SCRIPT -->', '<!-- FINGERPRINT_CHALLENGE_SCRIPT -->', '<!-- FINGERPRINT_TRAPS -->'],
302
+ [$solverCode, $challengeScript, $trapContainerHtml],
303
+ $htmlTemplate
304
+ );
305
+ }
306
+ }