@anonympins/fingerprint 0.5.0 → 0.5.2

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.
@@ -14,6 +14,48 @@ use Anonympins\Fingerprint\Utils\RequestUtils;
14
14
  */
15
15
  class ChallengeUtils
16
16
  {
17
+ private static function fround(float $value): float
18
+ {
19
+ return unpack('f', pack('f', $value))[1];
20
+ }
21
+
22
+ public static function hashSeedToFloat(string $seed): float
23
+ {
24
+ $hash = 0;
25
+ for ($i = 0; $i < strlen($seed); $i++) {
26
+ $hash = (($hash << 5) - $hash + ord($seed[$i])) & 0xffffffff;
27
+ if ($hash & 0x80000000) {
28
+ $hash = $hash - 0x100000000;
29
+ }
30
+ }
31
+ return abs($hash % 1000000) / 1000000;
32
+ }
33
+
34
+ public static function verifyGpuPow(string $seed, int $iterations, string $solution, array $sampleIndices = [0, 12, 35, 57]): bool
35
+ {
36
+ $values = explode(',', $solution);
37
+ if (count($values) !== 64) {
38
+ return false;
39
+ }
40
+ $numericSeed = self::hashSeedToFloat($seed);
41
+ $r = 3.9999;
42
+ foreach ($sampleIndices as $idx) {
43
+ if ($idx < 0 || $idx >= 64) {
44
+ return false;
45
+ }
46
+ $x = self::fround($numericSeed + $idx * 0.015);
47
+ $rFloat = self::fround($r);
48
+ for ($i = 0; $i < $iterations; $i++) {
49
+ $x = self::fround($rFloat * $x * self::fround(1.0 - $x));
50
+ }
51
+ $clientVal = (float)$values[$idx];
52
+ if (abs($clientVal - $x) > 1e-4) {
53
+ return false;
54
+ }
55
+ }
56
+ return true;
57
+ }
58
+
17
59
  private const TRAP_URL_TEMPLATES = [
18
60
  '/includes/config-{RANDOM}.php',
19
61
  '/.env.{RANDOM}',
@@ -596,6 +638,55 @@ class ChallengeUtils
596
638
  /**
597
639
  * Vérifie une solution de PoW mémoire.
598
640
  */
641
+ private static function getChallengedIndices(string $seed, int $solution, int $numBlocks, int $k = 4): array
642
+ {
643
+ $indices = [];
644
+ $h = (int)bcmod(\Anonympins\Fingerprint\FingerprintBuilder::cyrb53($seed . ":" . $solution), '4294967296');
645
+ for ($i = 0; $i < $k; $i++) {
646
+ $h = self::gmp_imul($h ^ $i, 1597334677);
647
+ $indices[] = abs($h) % $numBlocks;
648
+ }
649
+ return $indices;
650
+ }
651
+
652
+ private static function verifyMerkleProof(string $leafHash, int $index, array $proof, string $root): bool
653
+ {
654
+ $currentHash = $leafHash;
655
+ $idx = $index;
656
+ foreach ($proof as $sibling) {
657
+ $combined = ($idx % 2 === 0) ? $currentHash . $sibling : $sibling . $currentHash;
658
+ $currentHash = hash('sha256', hex2bin($combined));
659
+ $idx = (int)floor($idx / 2);
660
+ }
661
+ return $currentHash === $root;
662
+ }
663
+
664
+ private static function verifyMemoryPoWLegacy(string $nonce, int $solution, int $difficulty, string $clientSecret): bool
665
+ {
666
+ $size = $difficulty * 1024 * 1024;
667
+ $iterations = (int)floor($size / 16);
668
+ $buffer = new \SplFixedArray((int)floor($size / 4));
669
+
670
+ $seed = ":{$nonce}:{$clientSecret}";
671
+ $h = 0;
672
+ foreach (unpack('C*', $seed) as $byte) {
673
+ $h += $byte;
674
+ }
675
+
676
+ for ($i = 0; $i < count($buffer); $i++) {
677
+ $buffer[$i] = $h = self::gmp_imul($h ^ $i, 1597334677);
678
+ }
679
+
680
+ $finalHash = 0;
681
+ $addr = count($buffer) > 0 ? $buffer[0] % count($buffer) : 0;
682
+ for ($i = 0; $i < $iterations; $i++) {
683
+ $addr = $buffer[$addr] % count($buffer);
684
+ $finalHash ^= $addr;
685
+ }
686
+
687
+ return $finalHash === $solution;
688
+ }
689
+
599
690
  public static function verifyMemoryPoW(
600
691
  string $nonce,
601
692
  string $solution,
@@ -612,37 +703,69 @@ class ChallengeUtils
612
703
  return false;
613
704
  }
614
705
 
615
- // If difficulty is high (production workloads), we treat memory PoW purely as a client-side cost.
616
- // Cryptographic integrity is already fully enforced by the chained CPU PoW verification.
617
- if ($difficulty > 4) {
618
- return true;
706
+ $data = json_decode($solution, true);
707
+ if (json_last_error() !== JSON_ERROR_NONE || !isset($data['solution']) || !isset($data['merkleRoot']) || !isset($data['proofs'])) {
708
+ if ($difficulty <= 4 && preg_match('/^\d+$/', $solution)) {
709
+ return self::verifyMemoryPoWLegacy($nonce, (int)$solution, $difficulty, $clientSecret);
710
+ }
711
+ return false;
619
712
  }
620
713
 
621
- $size = $difficulty * 1024 * 1024;
622
- if ($size <= 0) {
623
- return true; // Pas de challenge mémoire si la difficulté est nulle ou négative.
624
- }
625
- $iterations = (int)floor($size / 16);
626
- $buffer = new \SplFixedArray((int)floor($size / 4));
714
+ $sol = $data['solution'];
715
+ $merkleRoot = $data['merkleRoot'];
716
+ $proofs = $data['proofs'];
627
717
 
718
+ $numBlocks = $difficulty * 256;
628
719
  $seed = ":{$nonce}:{$clientSecret}";
629
- $h = 0;
630
- foreach (unpack('C*', $seed) as $byte) {
631
- $h += $byte;
632
- }
633
720
 
634
- for ($i = 0; $i < count($buffer); $i++) {
635
- $buffer[$i] = $h = self::gmp_imul($h ^ $i, 1597334677);
721
+ $challengedIndices = self::getChallengedIndices($seed, (int)$sol, $numBlocks, 4);
722
+
723
+ foreach ($challengedIndices as $b) {
724
+ $proof = $proofs[$b] ?? $proofs[(string)$b] ?? null;
725
+ if ($proof === null) return false;
726
+
727
+ $blockBytes = '';
728
+ $h = (int)bcmod(\Anonympins\Fingerprint\FingerprintBuilder::cyrb53($seed . ":" . $b), '4294967296');
729
+ for ($i = 0; $i < 1024; $i++) {
730
+ $h = self::gmp_imul($h ^ $i, 1597334677);
731
+ $blockBytes .= pack('V', $h);
732
+ }
733
+
734
+ $expectedLeaf = hash('sha256', $blockBytes);
735
+
736
+ if (!self::verifyMerkleProof($expectedLeaf, $b, $proof, $merkleRoot)) {
737
+ return false;
738
+ }
636
739
  }
637
740
 
638
- $finalHash = 0;
639
- $addr = count($buffer) > 0 ? $buffer[0] % count($buffer) : 0;
741
+ $blockCache = [];
742
+ $getBlockElement = function (int $blockIdx, int $elementIdx) use (&$blockCache, $seed): int {
743
+ if (!isset($blockCache[$blockIdx])) {
744
+ $block = [];
745
+ $h = (int)bcmod(\Anonympins\Fingerprint\FingerprintBuilder::cyrb53($seed . ":" . $blockIdx), '4294967296');
746
+ for ($i = 0; $i < 1024; $i++) {
747
+ $h = self::gmp_imul($h ^ $i, 1597334677);
748
+ $block[$i] = $h;
749
+ }
750
+ $blockCache[$blockIdx] = $block;
751
+ }
752
+ return $blockCache[$blockIdx][$elementIdx];
753
+ };
754
+
755
+ $totalElements = $numBlocks * 1024;
756
+ $addr = $totalElements > 0 ? $getBlockElement(0, 0) % $totalElements : 0;
757
+ $addr = $addr & 0xffffffff;
758
+ $expectedSolution = 0;
759
+ $iterations = 1024;
640
760
  for ($i = 0; $i < $iterations; $i++) {
641
- $addr = $buffer[$addr] % count($buffer);
642
- $finalHash ^= $addr;
761
+ $blockIdx = (int)floor($addr / 1024);
762
+ $elementIdx = $addr % 1024;
763
+ $addr = $getBlockElement($blockIdx, $elementIdx) % $totalElements;
764
+ $addr = $addr & 0xffffffff;
765
+ $expectedSolution ^= $addr;
643
766
  }
644
767
 
645
- return $finalHash === (int)$solution;
768
+ return $expectedSolution === (int)$sol;
646
769
  }
647
770
 
648
771
  /**
@@ -717,11 +840,15 @@ class ChallengeUtils
717
840
  $solverCode = self::getPowSolverCode();
718
841
  $queriesJson = json_encode($queries);
719
842
 
843
+ $safePath = json_encode($path, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
844
+ $safeNonce = json_encode($nonce, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
845
+ $safeClientSecret = json_encode($clientSecret, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
846
+
720
847
  $challengeScript = <<<JS
721
848
  async function solve() {
722
- const nonce = "{$nonce}";
723
- const path = "{$path}";
724
- const clientSecret = "{$clientSecret}";
849
+ const nonce = {$safeNonce};
850
+ const path = {$safePath};
851
+ const clientSecret = {$safeClientSecret};
725
852
  const queries = {$queriesJson};
726
853
  const sizeMb = {$sizeMb};
727
854
  const nodeId = "{$nodeId}";
@@ -824,11 +951,15 @@ JS;
824
951
  ));
825
952
  $trapContainerHtml = "<div style=\"position:absolute;left:-9999px;top:-9999px;transform:scale(0);pointer-events:none;\" aria-hidden=\"true\">{$trapLinksHtml}</div>";
826
953
 
954
+ $safePath = json_encode($path, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
955
+ $safeNonce = json_encode($nonce, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
956
+ $safeClientSecret = json_encode($clientSecret, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES);
957
+
827
958
  $challengeScript = <<<JS
828
959
  async function solve() {
829
- const nonce = "{$nonce}";
830
- const path = "{$path}";
831
- const clientSecret = "{$clientSecret}";
960
+ const nonce = {$safeNonce};
961
+ const path = {$safePath};
962
+ const clientSecret = {$safeClientSecret};
832
963
  const cpuTarget = BigInt("0x" + "{$target}");
833
964
  const memDifficulty = {$memoryDifficulty};
834
965
  const baseBlock = new Uint8Array({$baseBlockBytes});
@@ -41,6 +41,8 @@ class SecurityProfiles
41
41
  'subnetScore' => 0.5, // Pénalise les sous-réseaux IP avec une activité suspecte agrégée
42
42
  'botnetClusterScore' => 0.6, // NOUVEAU: Poids pour le clustering botnet
43
43
  'tcpAnomalyScore' => 0.8, // NEW: Anomalie de pile TCP/IP
44
+ 'quicAnomalyScore' => 0.8, // NEW: Anomalie QUIC
45
+ 'renderingAnomalyScore' => 0.8, // NEW: Anomalie de rendu
44
46
 
45
47
  ],
46
48
  'thresholds' => ['low' => 20, 'medium' => 45, 'high' => 75, 'block' => 95],
@@ -87,6 +89,8 @@ class SecurityProfiles
87
89
  'subnetScore' => 0.7, // Poids plus élevé en mode strict
88
90
  'botnetClusterScore' => 0.8, // NOUVEAU: Poids pour le clustering botnet
89
91
  'tcpAnomalyScore' => 1.0, // NEW: Anomalie de pile TCP/IP
92
+ 'quicAnomalyScore' => 1.0, // NEW: Anomalie QUIC
93
+ 'renderingAnomalyScore' => 1.0, // NEW: Anomalie de rendu
90
94
 
91
95
  ],
92
96
  'thresholds' => ['low' => 10, 'medium' => 35, 'high' => 65, 'block' => 90],
@@ -133,6 +137,7 @@ class SecurityProfiles
133
137
  'subnetScore' => 0.8, // Très important pour les API pour détecter les botnets
134
138
  'botnetClusterScore' => 0.7, // NOUVEAU: Poids pour le clustering botnet
135
139
  'tcpAnomalyScore' => 0.8, // NEW: Anomalie de pile TCP/IP
140
+ 'quicAnomalyScore' => 0.8, // NEW: Anomalie QUIC
136
141
 
137
142
  ],
138
143
  'thresholds' => ['low' => 25, 'medium' => 50, 'high' => 80, 'block' => 95],
@@ -181,6 +186,8 @@ class SecurityProfiles
181
186
  'subnetScore' => 0.4, // Utile contre le spam de commentaires coordonné
182
187
  'botnetClusterScore' => 0.5, // NOUVEAU: Poids pour le clustering botnet
183
188
  'tcpAnomalyScore' => 0.5, // NEW: Anomalie de pile TCP/IP
189
+ 'quicAnomalyScore' => 0.5, // NEW: Anomalie QUIC
190
+ 'renderingAnomalyScore' => 0.5, // NEW: Anomalie de rendu
184
191
 
185
192
  ],
186
193
  'thresholds' => ['low' => 25, 'medium' => 55, 'high' => 80, 'block' => 95],
@@ -228,6 +235,8 @@ class SecurityProfiles
228
235
  'subnetScore' => 0.9, // Crucial contre les attaques de scalping distribuées
229
236
  'botnetClusterScore' => 0.9, // NOUVEAU: Poids pour le clustering botnet
230
237
  'tcpAnomalyScore' => 0.9, // NEW: Anomalie de pile TCP/IP
238
+ 'quicAnomalyScore' => 0.9, // NEW: Anomalie QUIC
239
+ 'renderingAnomalyScore' => 0.9, // NEW: Anomalie de rendu
231
240
 
232
241
  ],
233
242
  'thresholds' => ['low' => 15, 'medium' => 40, 'high' => 70, 'block' => 90],
@@ -27,6 +27,10 @@
27
27
  private ?Logger $logger = null;
28
28
  private bool $dryRun;
29
29
 
30
+ private static ?array $googlebotEntries = null;
31
+ private static ?array $yandexEntries = null;
32
+ private static ?array $bingbotEntries = null;
33
+
30
34
  public function __construct(array $securityConfig)
31
35
  {
32
36
  $this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
@@ -61,6 +65,19 @@
61
65
  $this->validateConfig($securityConfig);
62
66
  }
63
67
 
68
+ /**
69
+ * Applique à chaud une nouvelle configuration de sécurité (poids, seuils, etc.)
70
+ * sans nécessiter de redémarrage.
71
+ *
72
+ * @param array $newConfig La nouvelle configuration (partielle ou complète).
73
+ */
74
+ public function updateConfig(array $newConfig): void
75
+ {
76
+ $this->validateConfig($newConfig);
77
+ $this->securityConfig = SecurityProfiles::deepMerge($this->securityConfig, $newConfig);
78
+ $this->log('Configuration mise à jour à chaud (Hot-Reloaded)', $this->securityConfig);
79
+ }
80
+
64
81
  private function validateConfig(array $config): void
65
82
  {
66
83
  if (empty($config)) {
@@ -90,7 +107,103 @@
90
107
  }
91
108
  }
92
109
  }
110
+ private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
111
+ {
112
+ $configDir = dirname(__DIR__, 2) . '/config';
113
+ $filePath = $configDir . '/' . $filename;
114
+ if (file_exists($filePath)) {
115
+ try {
116
+ $content = file_get_contents($filePath);
117
+ if ($content !== false) {
118
+ $decoded = json_decode($content, true);
119
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
120
+ return $decoded;
121
+ }
122
+ }
123
+ } catch (\Throwable $e) {
124
+ error_log("[Fingerprint] Error loading whitelist file {$filename}: " . $e->getMessage());
125
+ }
126
+ }
127
+ return $fallbackEntries;
128
+ }
129
+
130
+ public static function googlebot_whitelist(): array
131
+ {
132
+ if (self::$googlebotEntries === null) {
133
+ self::$googlebotEntries = self::loadBotWhitelist('googlebot.json', [
134
+ "2001:4860:4801:10::/64",
135
+ "2001:4860:4801:11::/64",
136
+ "2001:4860:4801:12::/64",
137
+ "66.249.79.64"
138
+ ]);
139
+ }
140
+ return [
141
+ 'type' => 'allowlist',
142
+ 'entries' => self::$googlebotEntries
143
+ ];
144
+ }
93
145
 
146
+ public static function yandex_whitelist(): array
147
+ {
148
+ if (self::$yandexEntries === null) {
149
+ self::$yandexEntries = self::loadBotWhitelist('yandex.json', [
150
+ "2a02:6b8::/29",
151
+ "5.45.192.0/18",
152
+ "213.180.192.0/19"
153
+ ]);
154
+ }
155
+ return [
156
+ 'type' => 'allowlist',
157
+ 'entries' => self::$yandexEntries
158
+ ];
159
+ }
160
+
161
+ public static function bingbot_whitelist(): array
162
+ {
163
+ if (self::$bingbotEntries === null) {
164
+ self::$bingbotEntries = self::loadBotWhitelist('bingbot.json', [
165
+ "157.55.39.0/24",
166
+ "207.46.13.0/24",
167
+ "40.77.178.0/23"
168
+ ]);
169
+ }
170
+ return [
171
+ 'type' => 'allowlist',
172
+ 'entries' => self::$bingbotEntries
173
+ ];
174
+ }
175
+
176
+ public static function default_whitelist(): array
177
+ {
178
+ return [
179
+ self::googlebot_whitelist(),
180
+ self::bingbot_whitelist(),
181
+ self::yandex_whitelist(),
182
+ ['userAgent' => 'Googlebot', 'hostnameSuffix' => '.googlebot.com'],
183
+ ['userAgent' => 'Google-Extended', 'hostnameSuffix' => '.google.com'],
184
+ ['userAgent' => 'AdsBot-Google', 'hostnameSuffix' => '.googlebot.com'],
185
+ ['userAgent' => 'Mediapartners-Google', 'hostnameSuffix' => '.google.com'],
186
+ ['userAgent' => 'Google-InspectionTool', 'hostnameSuffix' => '.google.com'],
187
+ ['userAgent' => '(bingbot|adidxbot)', 'hostnameSuffix' => '.search.msn.com'],
188
+ ['userAgent' => 'DuckDuckBot', 'hostnameSuffix' => '.duckduckgo.com'],
189
+ ['userAgent' => 'YandexBot', 'hostnameSuffix' => '.yandex.com'],
190
+ ['userAgent' => 'YandexImages', 'hostnameSuffix' => '.yandex.com'],
191
+ ['userAgent' => 'Baiduspider', 'hostnameSuffix' => '.crawl.baidu.com'],
192
+ ['userAgent' => 'Slurp', 'hostnameSuffix' => '.crawl.yahoo.net'],
193
+ ['userAgent' => 'Sogou web spider', 'hostnameSuffix' => '.sogou.com'],
194
+ ['userAgent' => 'Exabot', 'hostnameSuffix' => '.exabot.com'],
195
+ ['userAgent' => 'ia_archiver', 'hostnameSuffix' => '.alexa.com'],
196
+ ['userAgent' => 'SeznamBot', 'hostnameSuffix' => '.seznam.cz'],
197
+ ['userAgent' => 'Mail.RU_Bot', 'hostnameSuffix' => '.mail.ru'],
198
+ ['userAgent' => 'Yeti', 'hostnameSuffix' => '.naver.com'],
199
+ ['userAgent' => 'AhrefsBot', 'hostnameSuffix' => '.ahrefs.com'],
200
+ ['userAgent' => 'SemrushBot', 'hostnameSuffix' => '.semrush.com'],
201
+ ['userAgent' => 'MJ12bot', 'hostnameSuffix' => '.mj12bot.com'],
202
+ ['userAgent' => 'rogerbot', 'hostnameSuffix' => '.moz.com'],
203
+ ['userAgent' => 'DotBot', 'hostnameSuffix' => '.moz.com'],
204
+ ['userAgent' => 'Screaming Frog SEO Spider', 'hostnameSuffix' => '.screamingfrog.co.uk'],
205
+ ];
206
+ }
94
207
  private function log(string $message, array $data = [], string $level = 'info'): void
95
208
  {
96
209
  if ($this->verbose) {
@@ -467,6 +580,12 @@
467
580
  // Score d'anomalie de pile TCP/IP
468
581
  $tcpAnomaly = RequestUtils::getTcpAnomalyScore($context);
469
582
 
583
+ // Score d'anomalie de flux QUIC/HTTP3
584
+ $quicAnomaly = RequestUtils::getQuicAnomalyScore($context);
585
+
586
+ // Score d'anomalie de rendu d'affichage (V-Sync)
587
+ $renderingAnomaly = RequestUtils::getRenderingAnomalyScore($context);
588
+
470
589
  // Assemblage du vecteur de suspicion final
471
590
  $suspicionVector = array_merge($suspicionVector, [
472
591
  'inconsistencyScore' => $inconsistencyScore,
@@ -486,6 +605,8 @@
486
605
  'subnetScore' => $subnetScore['subnetScore'],
487
606
  'botnetClusterScore' => $botnetCluster['botnetClusterScore'],
488
607
  'tcpAnomalyScore' => $tcpAnomaly['tcpAnomalyScore'],
608
+ 'quicAnomalyScore' => $quicAnomaly['quicAnomalyScore'],
609
+ 'renderingAnomalyScore' => $renderingAnomaly['renderingAnomalyScore'],
489
610
  ]);
490
611
 
491
612
  // Sauvegarder l'état mis à jour de l'appareil dans le store
@@ -1018,8 +1139,9 @@
1018
1139
  'target' => ChallengeUtils::calculateCpuTarget($suspicionFactor, $this->securityConfig),
1019
1140
  'path' => $context->path,
1020
1141
  ];
1021
-
1022
- $memActivationFactor = max(0, ($suspicionFactor - 0.25) / 0.75);
1142
+
1143
+ // Alignement linéaire parfait du ratio d'effort CPU/Mémoire
1144
+ $memActivationFactor = $suspicionFactor;
1023
1145
  $memDifficulty = (int)round($memActivationFactor * 48); // 0 à 48MB
1024
1146
 
1025
1147
  $originalFingerprint = RequestUtils::getCompositeDeviceHash($context);
@@ -38,6 +38,7 @@ class RequestContext
38
38
  public ?string $ja4s = null;
39
39
  public ?string $ja4h = null;
40
40
  public ?string $http2Fingerprint = null;
41
+ public ?string $quicFingerprint = null;
41
42
  public ?string $tcpFingerprint = null;
42
43
 
43
44
  /**
@@ -77,6 +78,7 @@ class RequestContext
77
78
  $this->ja4h = $this->headers['x-ja4h-hash'] ?? null;
78
79
  $this->http2Fingerprint = $this->headers['x-http2-fingerprint'] ?? null;
79
80
  $this->tcpFingerprint = $this->headers['x-tcp-fingerprint'] ?? null;
81
+ $this->quicFingerprint = $this->headers['x-quic-fp'] ?? null;
80
82
  $this->tlsSessionId = $this->headers['x-tls-session-id'] ?? $this->headers['x-ssl-session-id'] ?? null;
81
83
  }
82
84
  /**
@@ -10,6 +10,83 @@ use PHPUnit\Framework\TestCase;
10
10
 
11
11
  class ChallengeUtilsTest extends TestCase
12
12
  {
13
+ public function testGpuPowDeterministicHashing(): void
14
+ {
15
+ $seed = 'test-seed-determinism';
16
+ $f1 = ChallengeUtils::hashSeedToFloat($seed);
17
+ $f2 = ChallengeUtils::hashSeedToFloat($seed);
18
+ $f3 = ChallengeUtils::hashSeedToFloat('different-seed');
19
+
20
+ $this->assertEquals($f1, $f2, 'Seed hashing must be deterministic.');
21
+ $this->assertGreaterThanOrEqual(0, $f1);
22
+ $this->assertLessThan(1, $f1);
23
+ $this->assertNotEquals($f1, $f3);
24
+ }
25
+
26
+ public function testGpuPowVerificationRejectsTamperedSolution(): void
27
+ {
28
+ $seed = 'php-verification-seed';
29
+ $iterations = 100;
30
+
31
+ // Generate correct solutions mimicking the GPU solver trajectory
32
+ $numericSeed = ChallengeUtils::hashSeedToFloat($seed);
33
+ $r = 3.9999;
34
+ $solutions = [];
35
+ $fround = function (float $value): float {
36
+ return unpack('f', pack('f', $value))[1];
37
+ };
38
+
39
+ for ($idx = 0; $idx < 64; $idx++) {
40
+ $x = $fround($numericSeed + $idx * 0.015);
41
+ $rFloat = $fround($r);
42
+ for ($i = 0; $i < $iterations; $i++) {
43
+ $x = $fround($rFloat * $x * $fround(1.0 - $x));
44
+ }
45
+ $solutions[] = number_format($x, 6, '.', '');
46
+ }
47
+ $validSolution = implode(',', $solutions);
48
+
49
+ $this->assertTrue(ChallengeUtils::verifyGpuPow($seed, $iterations, $validSolution));
50
+
51
+ // Tamper with value on verified sample index 12
52
+ $tampered = $solutions;
53
+ $tampered[12] = (string)((float)$tampered[12] + 0.0002); // Out of tolerance
54
+ $this->assertFalse(ChallengeUtils::verifyGpuPow($seed, $iterations, implode(',', $tampered)));
55
+ }
56
+
57
+ public function testGpuPowVerification(): void
58
+ {
59
+ $seed = 'php-gpu-test-seed';
60
+ $iterations = 50;
61
+
62
+ $hash = 0;
63
+ for ($i = 0; $i < strlen($seed); $i++) {
64
+ $hash = (($hash << 5) - $hash + ord($seed[$i])) & 0xffffffff;
65
+ if ($hash & 0x80000000) {
66
+ $hash = $hash - 0x100000000;
67
+ }
68
+ }
69
+ $numericSeed = abs($hash % 1000000) / 1000000;
70
+ $r = 3.9999;
71
+ $solutions = [];
72
+ $fround = function (float $value): float {
73
+ return unpack('f', pack('f', $value))[1];
74
+ };
75
+ for ($idx = 0; $idx < 64; $idx++) {
76
+ $x = $fround($numericSeed + $idx * 0.015);
77
+ $rFloat = $fround($r);
78
+ for ($i = 0; $i < $iterations; $i++) {
79
+ $x = $fround($rFloat * $x * $fround(1.0 - $x));
80
+ }
81
+ $solutions[] = number_format($x, 6, '.', '');
82
+ }
83
+ $solutionString = implode(',', $solutions);
84
+ $this->assertTrue(ChallengeUtils::verifyGpuPow($seed, $iterations, $solutionString));
85
+ $tamperedSolutions = $solutions;
86
+ $tamperedSolutions[0] = (string)((float)$tamperedSolutions[0] + 0.1);
87
+ $this->assertFalse(ChallengeUtils::verifyGpuPow($seed, $iterations, implode(',', $tamperedSolutions)));
88
+ }
89
+
13
90
  protected function setUp(): void
14
91
  {
15
92
  // Définir une clé secrète pour les tests
@@ -0,0 +1,54 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\RequestContext;
8
+ use Anonympins\Fingerprint\Utils\RequestUtils;
9
+ use PHPUnit\Framework\TestCase;
10
+
11
+ class QuicFingerprintTest extends TestCase
12
+ {
13
+ public function testGetQuicAnomalyScoreReturnsZeroIfNoFp(): void
14
+ {
15
+ $context = $this->createMock(RequestContext::class);
16
+ $context->method('getHeader')->willReturn(null);
17
+ $res = RequestUtils::getQuicAnomalyScore($context);
18
+ $this->assertEquals(0.0, $res['quicAnomalyScore']);
19
+ }
20
+
21
+ public function testGetQuicAnomalyScoreDetectsSpoofedChrome(): void
22
+ {
23
+ $context = $this->createMock(RequestContext::class);
24
+ $context->method('getHeader')->willReturnCallback(function($name) {
25
+ if ($name === 'user-agent') {
26
+ return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
27
+ }
28
+ if ($name === 'x-quic-fp') {
29
+ return '1;1=65536,4=50;i=0';
30
+ }
31
+ return null;
32
+ });
33
+
34
+ $res = RequestUtils::getQuicAnomalyScore($context);
35
+ $this->assertEquals(100.0, $res['quicAnomalyScore']);
36
+ }
37
+
38
+ public function testGetQuicAnomalyScoreAllowsLegitChrome(): void
39
+ {
40
+ $context = $this->createMock(RequestContext::class);
41
+ $context->method('getHeader')->willReturnCallback(function($name) {
42
+ if ($name === 'user-agent') {
43
+ return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
44
+ }
45
+ if ($name === 'x-quic-fp') {
46
+ return '1;1=1572864,4=100;u=2,i';
47
+ }
48
+ return null;
49
+ });
50
+
51
+ $res = RequestUtils::getQuicAnomalyScore($context);
52
+ $this->assertEquals(0.0, $res['quicAnomalyScore']);
53
+ }
54
+ }
@@ -48,6 +48,28 @@ class RequestUtilsTest extends TestCase
48
48
  $score = RequestUtils::getCrossLayerInconsistency($context);
49
49
  $this->assertEquals(20.0, $score['crossLayerInconsistencyScore']);
50
50
  }
51
+ public function testGetRenderingAnomalyScoreEmpty(): void
52
+ {
53
+ $context = $this->createRequestContext();
54
+ $res = RequestUtils::getRenderingAnomalyScore($context);
55
+ $this->assertEquals(0.0, $res['renderingAnomalyScore']);
56
+ }
57
+
58
+ public function testGetRenderingAnomalyScoreSpoofed(): void
59
+ {
60
+ $metrics = [
61
+ 'rendering' => [
62
+ 'fps' => 60,
63
+ 'jitter' => 12.5,
64
+ 'offscreenAnom' => false
65
+ ]
66
+ ];
67
+ $context = $this->createRequestContext([
68
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
69
+ ]);
70
+ $res = RequestUtils::getRenderingAnomalyScore($context);
71
+ $this->assertEquals(65.0, $res['renderingAnomalyScore']);
72
+ }
51
73
  public function testGetClickVarianceScoreReturnsZeroForNoHistory(): void
52
74
  {
53
75
  $context = $this->createRequestContext([
@@ -185,6 +207,27 @@ class RequestUtilsTest extends TestCase
185
207
  $this->assertEquals(40.0, $scoreNoActivity['behaviorScore']);
186
208
  }
187
209
 
210
+ public function testGetBehaviorScoreWithBotLikeKeystrokeDynamics(): void
211
+ {
212
+ $metrics = [
213
+ 'honeypotInteraction' => false,
214
+ 'keystrokeLatency' => 100.0,
215
+ 'keystrokeDwellTimes' => [50, 50, 50, 50, 50],
216
+ 'keystrokeFlightTimes' => [
217
+ ['digraph' => 'ab', 'time' => 100],
218
+ ['digraph' => 'bc', 'time' => 100],
219
+ ['digraph' => 'cd', 'time' => 100],
220
+ ['digraph' => 'de', 'time' => 100],
221
+ ['digraph' => 'ef', 'time' => 100]
222
+ ]
223
+ ];
224
+ $context = $this->createRequestContext([
225
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
226
+ ]);
227
+ $score = RequestUtils::getBehaviorScore($context);
228
+ $this->assertGreaterThanOrEqual(70.0, $score['behaviorScore']);
229
+ }
230
+
188
231
  public function testGetBehaviorScoreWithBotLikeTouchMovements(): void
189
232
  {
190
233
  $metrics = [