@anonympins/fingerprint 0.8.0 → 0.8.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.
@@ -173,11 +173,12 @@ class FingerprintEngine
173
173
  Env::set('ED25519_PUBLIC_KEY', $storedKeys['publicKey']);
174
174
  } elseif (defined('OPENSSL_KEYTYPE_ED25519')) {
175
175
  $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
176
- $pemResult = ChallengeUtils::generateIssuerPemKeys();
177
- $privateKeyPem = $pemResult['privateKey'];
178
- $publicKeyPem = $pemResult['publicKey'];
179
- Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
180
- Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
176
+ if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
177
+ $details = openssl_pkey_get_details($pkey);
178
+ $publicKeyPem = $details['key'] ?? '';
179
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
180
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
181
+ }
181
182
  update_option('fingerprint_ed25519_keys', [
182
183
  'privateKey' => $privateKeyPem,
183
184
  'publicKey' => $publicKeyPem,
@@ -227,12 +228,25 @@ class FingerprintEngine
227
228
  */
228
229
  private static function initStandaloneEd25519Keys(): void
229
230
  {
230
- $configDir = dirname(__DIR__, 1) . '/config';
231
- $persistentKeyPath = $configDir . '/ed25519_key.json';
231
+ if (function_exists('get_option')) {
232
+ return; // Managed exclusively via WordPress options in WP runtime
233
+ }
234
+
235
+ if (function_exists('wp_upload_dir') && function_exists('wp_mkdir_p')) {
236
+ $uploadDir = wp_upload_dir();
237
+ $configDir = (!empty($uploadDir['basedir']) && is_dir($uploadDir['basedir']))
238
+ ? $uploadDir['basedir'] . '/anonympins-bot-mitigation-pow'
239
+ : sys_get_temp_dir();
240
+ } else {
241
+ $envKeyDir = Env::get('FINGERPRINT_KEY_DIR');
242
+ $configDir = !empty($envKeyDir) ? (string)$envKeyDir : sys_get_temp_dir();
243
+ }
244
+ $persistentKeyPath = rtrim($configDir, '/\\') . '/ed25519_key.json';
232
245
 
233
246
  if (file_exists($persistentKeyPath)) {
234
247
  try {
235
- $keys = json_decode((string)file_get_contents($persistentKeyPath), true);
248
+ $raw = (string)file_get_contents($persistentKeyPath);
249
+ $keys = json_decode($raw, true);
236
250
  if (isset($keys['privateKey'], $keys['publicKey'])) {
237
251
  Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
238
252
  Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
@@ -253,11 +267,11 @@ class FingerprintEngine
253
267
  if (function_exists('wp_mkdir_p')) {
254
268
  wp_mkdir_p($configDir);
255
269
  } else {
256
- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Non-WordPress standalone environment fallback
257
- mkdir($configDir, 0777, true);
270
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Standalone filesystem fallback
271
+ @mkdir($configDir, 0700, true);
258
272
  }
259
273
  }
260
- // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- Non-WordPress standalone environment fallback
274
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
261
275
  file_put_contents($persistentKeyPath, json_encode([
262
276
  'privateKey' => $privateKeyPem,
263
277
  'publicKey' => $publicKeyPem
@@ -346,7 +360,13 @@ class FingerprintEngine
346
360
  */
347
361
  private static function loadBotWhitelist(string $filename, array $fallbackEntries): array
348
362
  {
349
- $configDir = dirname(__DIR__, 2) . '/config';
363
+ if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
364
+ $configDir = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config';
365
+ } elseif (is_dir(__DIR__ . '/config')) {
366
+ $configDir = __DIR__ . '/config';
367
+ } else {
368
+ $configDir = __DIR__;
369
+ }
350
370
  $filePath = $configDir . '/' . $filename;
351
371
  if (file_exists($filePath)) {
352
372
  try {
@@ -670,6 +690,21 @@ class FingerprintEngine
670
690
  */
671
691
  private function checkAllowlists(RequestContext $context): bool
672
692
  {
693
+ // Auto-whitelisting des pairs fédérés
694
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
695
+ if (!empty($peers)) {
696
+ $allowedHosts = array_map(function ($url) {
697
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Covered by wp_parse_url, Fallback for standalone PHP environments
698
+ $host = function_exists('wp_parse_url') ? wp_parse_url($url, PHP_URL_HOST) : parse_url($url, PHP_URL_HOST);
699
+ return !empty($host) ? $host : $url;
700
+ }, $peers);
701
+
702
+ if (in_array($context->clientIp, $allowedHosts, true)) {
703
+ $this->log("Federated peer auto-whitelisted", ['clientIp' => $context->clientIp]);
704
+ return true;
705
+ }
706
+ }
707
+
673
708
  $whitelisted = false;
674
709
  $type = '';
675
710
 
@@ -682,6 +717,15 @@ class FingerprintEngine
682
717
  } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
683
718
  $whitelisted = true;
684
719
  $type = 'host_path_allowlist';
720
+ } elseif (StoreManager::getStore()->has("federated-whitelist:ip:{$context->clientIp}")) {
721
+ $whitelisted = true;
722
+ $type = 'federated_whitelist_ip';
723
+ } elseif (($subnet = RequestUtils::getIpSubnet($context->clientIp)) && StoreManager::getStore()->has("federated-whitelist:subnet:{$subnet}")) {
724
+ $whitelisted = true;
725
+ $type = 'federated_whitelist_subnet';
726
+ } elseif (($ua = $context->getHeader('user-agent')) && StoreManager::getStore()->has("federated-whitelist:user_agent:{$ua}")) {
727
+ $whitelisted = true;
728
+ $type = 'federated_whitelist_user_agent';
685
729
  } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
686
730
  $whitelisted = true;
687
731
  $type = 'graphql_operation_allowlist';
@@ -876,12 +920,13 @@ class FingerprintEngine
876
920
  // --- Computation of the various suspicion scores ---
877
921
 
878
922
  // Fingerprint inconsistency score (moved here to be with the others)
923
+ $netProfile = AsnLookupEngine::getInstance()->lookup($context->clientIp);
879
924
  $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
880
925
  $consistencyScore = FingerprintBuilder::compare($deviceData['initialDeviceHash'] ?? '', $currentDeviceHash);
881
- $similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? 0.72);
926
+ $similarityThreshold = (float)($this->securityConfig['similarityThreshold'] ?? $netProfile->getInflectionPoint());
882
927
  $inconsistencyScore = RequestUtils::calculateAnalogInconsistencyScore($consistencyScore, $similarityThreshold);
883
928
 
884
- $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData);
929
+ $behavioral = RequestUtils::getBehavioralIndicators($context, $deviceData, $netProfile);
885
930
 
886
931
  // Header anomaly score
887
932
  $headerAnomalies = RequestUtils::getHeaderAnomalies($context);
@@ -1272,8 +1317,9 @@ class FingerprintEngine
1272
1317
  return $decision;
1273
1318
  }
1274
1319
 
1275
- // --- PRIVATE ACCESS TOKENS (PAT / RFC 9505 & Privacy Pass) ZERO-FRICTION BYPASS ---
1320
+ // --- PRIVATE ACCESS TOKENS (PAT / RFC 9578 & Privacy Pass) ZERO-FRICTION CHALLENGE EXEMPTION ---
1276
1321
  $rawPatTokens = RequestUtils::extractPrivateAccessTokens($context);
1322
+ $hasValidPat = false;
1277
1323
  if (!empty($rawPatTokens)) {
1278
1324
  $patConfig = $this->securityConfig['pat'] ?? [];
1279
1325
  foreach ($rawPatTokens as $rawToken) {
@@ -1285,16 +1331,12 @@ class FingerprintEngine
1285
1331
  if ($isValid) {
1286
1332
  $nonceTtl = $patConfig['nonceTtl'] ?? 86400;
1287
1333
  $store->set($nonceKey, true, $nonceTtl);
1288
- $this->log('Private Access Token (PAT) cryptographically verified - granting zero-friction bypass', [
1334
+ $this->log('Private Access Token (PAT) cryptographically verified - challenge exemption enabled', [
1289
1335
  'tokenType' => $parsedToken['tokenType'],
1290
1336
  'tokenKeyId' => $parsedToken['tokenKeyId']
1291
1337
  ]);
1292
- $decision = [
1293
- 'action' => 'next',
1294
- 'score' => 0.0,
1295
- 'vector' => ['pat_verified' => 100.0, 'privacy_pass' => 100.0]
1296
- ];
1297
- return $decision;
1338
+ $hasValidPat = true;
1339
+ break;
1298
1340
  }
1299
1341
  }
1300
1342
  }
@@ -1365,14 +1407,12 @@ class FingerprintEngine
1365
1407
  $isMemValid = true;
1366
1408
  } else {
1367
1409
  $memSolution = $context->query['pow_solution_mem'] ?? null;
1368
- $isMemValid = ($memSolution !== null && $memSolution !== '')
1369
- ? ChallengeUtils::verifyMemoryPoW(
1410
+ $isMemValid = $memSolution !== null && $memSolution !== '' && ChallengeUtils::verifyMemoryPoW(
1370
1411
  $powNonce,
1371
1412
  (string)$memSolution,
1372
1413
  $challengeContext['memDifficulty'] ?? 0,
1373
1414
  $challengeContext['clientSecret'] ?? ''
1374
- )
1375
- : false;
1415
+ );
1376
1416
  }
1377
1417
  $isValid = $isValid && $isMemValid;
1378
1418
  }
@@ -1410,8 +1450,9 @@ class FingerprintEngine
1410
1450
  'jsonLastError' => json_last_error_msg()
1411
1451
  ]);
1412
1452
  if (json_last_error() === JSON_ERROR_NONE) {
1413
- // @phpstan-ignore-next-line - The instance is managed by the singleton
1414
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1453
+ $defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
1454
+ ? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
1455
+ : (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
1415
1456
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1416
1457
  $problemManager = \Anonympins\Fingerprint\ProblemManager::getInstance($configPath, $store);
1417
1458
  // FIX: The solution is directly $workResult, not a sub-property.
@@ -1490,7 +1531,7 @@ class FingerprintEngine
1490
1531
  $deviceId = $context->cookies['device_id'] ?? '';
1491
1532
  $currentDeviceHash = RequestUtils::getCompositeDeviceHash($context);
1492
1533
  $allowRoaming = $this->securityConfig['allowCrossNetworkRoaming'] ?? false;
1493
- if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof)) {
1534
+ if (ChallengeUtils::isTicketValid($context->clientIp, $powCookie, $deviceId, $currentDeviceHash, $allowRoaming, $zkpProof) || $hasValidPat) {
1494
1535
  $hasValidTicket = true;
1495
1536
  MetricsManager::incrementCounter('tickets_valid_total');
1496
1537
  // We do not return immediately to allow re-challenging.
@@ -1517,6 +1558,9 @@ class FingerprintEngine
1517
1558
  }
1518
1559
 
1519
1560
  $suspicionVector = $context->preCalculatedVector ?? $this->getSuspicionVector($context, $suspicionVector);
1561
+ if ($hasValidPat) {
1562
+ $suspicionVector['pat_verified'] = 100.0;
1563
+ }
1520
1564
  $finalScore = $context->preCalculatedScore ?? $this->calculateFinalScore($suspicionVector);
1521
1565
  $this->log('Suspicion vector and final score calculated', [
1522
1566
  'finalScore' => round($finalScore, 2),
@@ -1692,7 +1736,9 @@ class FingerprintEngine
1692
1736
 
1693
1737
  if ($shouldUseUsefulWork) {
1694
1738
  $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1695
- $defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
1739
+ $defaultPath = defined('ANONYMPINS_BOT_MITIGATION_DIR')
1740
+ ? rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config/problems.config.json'
1741
+ : (file_exists(__DIR__ . '/config/problems.config.json') ? __DIR__ . '/config/problems.config.json' : null);
1696
1742
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1697
1743
  $problemManager = ProblemManager::getInstance($configPath, $store);
1698
1744
  $work = $problemManager->dispatchWork($finalScore);
@@ -1984,6 +2030,56 @@ class FingerprintEngine
1984
2030
  }
1985
2031
  }
1986
2032
 
2033
+ public function broadcastWhitelist(string $entry, string $entryType = 'ip', int $ttl = 86400): void
2034
+ {
2035
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
2036
+ if (empty($peers)) return;
2037
+
2038
+ $cleanEntry = trim($entry);
2039
+ if ($cleanEntry === '*' || $cleanEntry === '0.0.0.0/0' || $cleanEntry === '::/0') {
2040
+ throw new \InvalidArgumentException('Permissive wildcard entries are prohibited');
2041
+ }
2042
+ if ($cleanEntry === '') {
2043
+ return;
2044
+ }
2045
+
2046
+ $boundedTtl = min(604800, max(60, $ttl));
2047
+ $ts = (int)(microtime(true) * 1000);
2048
+ $msg = "{$ts}:whitelist:{$entryType}:{$cleanEntry}";
2049
+
2050
+ $privateKey = Env::get('ED25519_PRIVATE_KEY');
2051
+ if (!$privateKey) {
2052
+ throw new \RuntimeException('ED25519_PRIVATE_KEY is required for federated whitelist broadcast');
2053
+ }
2054
+
2055
+ $signature = '';
2056
+ try {
2057
+ $cleanKey = str_replace('\n', "\n", $privateKey);
2058
+ $pkeyObj = openssl_pkey_get_private($cleanKey);
2059
+ if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
2060
+ $signature = bin2hex($sigBytes);
2061
+ }
2062
+ } catch (\Throwable $e) {
2063
+ return;
2064
+ }
2065
+ if (empty($signature)) return;
2066
+
2067
+ foreach ($peers as $peerUrl) {
2068
+ $this->asyncPost($peerUrl . '?coop_op=share_whitelist', [
2069
+ 'entry' => $cleanEntry,
2070
+ 'entry_type' => $entryType,
2071
+ 'signature_ed25519' => $signature,
2072
+ 'timestamp' => $ts,
2073
+ 'ttl' => $boundedTtl,
2074
+ ]);
2075
+ }
2076
+ }
2077
+
2078
+ public function broadcastWhitelistedEntry(string $entry, string $entryType = 'ip', int $ttl = 86400): void
2079
+ {
2080
+ $this->broadcastWhitelist($entry, $entryType, $ttl);
2081
+ }
2082
+
1987
2083
  /**
1988
2084
  * Sends an asynchronous HTTP POST request to a given URL with the given
1989
2085
  * parameters (JSON-encoded). Uses non-blocking stream sockets with a short
@@ -0,0 +1,68 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ /**
8
+ * Profils de connectivité réseau et paramètres de modulation pour le scoring analogique.
9
+ */
10
+ class NetworkProfile
11
+ {
12
+ public const RESIDENTIAL = 'RESIDENTIAL';
13
+ public const CELLULAR = 'CELLULAR';
14
+ public const SATELLITE = 'SATELLITE';
15
+ public const HOSTING = 'HOSTING';
16
+ public const ANONYMIZER = 'ANONYMIZER';
17
+
18
+ private string $type;
19
+ private float $baseScore;
20
+ private float $inflectionPoint;
21
+ private bool $toleranceRotation;
22
+ private float $jitterTolerance;
23
+
24
+ public function __construct(
25
+ string $type,
26
+ float $baseScore,
27
+ float $inflectionPoint,
28
+ bool $toleranceRotation,
29
+ float $jitterTolerance
30
+ ) {
31
+ $this->type = $type;
32
+ $this->baseScore = $baseScore;
33
+ $this->inflectionPoint = $inflectionPoint;
34
+ $this->toleranceRotation = $toleranceRotation;
35
+ $this->jitterTolerance = $jitterTolerance;
36
+ }
37
+
38
+ public function getType(): string { return $this->type; }
39
+ public function getBaseScore(): float { return $this->baseScore; }
40
+ public function getInflectionPoint(): float { return $this->inflectionPoint; }
41
+ public function isToleranceRotation(): bool { return $this->toleranceRotation; }
42
+ public function getJitterTolerance(): float { return $this->jitterTolerance; }
43
+
44
+ public static function residential(): self
45
+ {
46
+ return new self(self::RESIDENTIAL, 0.0, 0.72, false, 60.0);
47
+ }
48
+
49
+ public static function cellular(): self
50
+ {
51
+ return new self(self::CELLULAR, 10.0, 0.60, true, 80.0);
52
+ }
53
+
54
+ public static function satellite(): self
55
+ {
56
+ return new self(self::SATELLITE, 15.0, 0.68, false, 150.0);
57
+ }
58
+
59
+ public static function hosting(): self
60
+ {
61
+ return new self(self::HOSTING, 55.0, 0.85, false, 30.0);
62
+ }
63
+
64
+ public static function anonymizer(): self
65
+ {
66
+ return new self(self::ANONYMIZER, 85.0, 0.90, false, 40.0);
67
+ }
68
+ }
@@ -36,8 +36,18 @@ class ProblemManager
36
36
  {
37
37
  if (self::$instance === null) {
38
38
  if ($configPath === null) {
39
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
40
- $configPath = file_exists($defaultPath) ? $defaultPath : null;
39
+ $candidateDirs = [];
40
+ if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
41
+ $candidateDirs[] = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/config';
42
+ }
43
+ $candidateDirs[] = __DIR__ . '/config';
44
+ foreach ($candidateDirs as $dir) {
45
+ $p = $dir . '/problems.config.json';
46
+ if (file_exists($p)) {
47
+ $configPath = $p;
48
+ break;
49
+ }
50
+ }
41
51
  }
42
52
  // Error if accessed before initialization
43
53
  if ($configPath === null || $store === null) {
@@ -0,0 +1,61 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\AsnLookupEngine;
8
+ use Anonympins\Fingerprint\NetworkProfile;
9
+ use Anonympins\Fingerprint\Utils\RequestUtils;
10
+ use PHPUnit\Framework\TestCase;
11
+
12
+ class AsnLookupTest extends TestCase
13
+ {
14
+ private AsnLookupEngine $engine;
15
+
16
+ protected function setUp(): void
17
+ {
18
+ parent::setUp();
19
+ $this->engine = AsnLookupEngine::getInstance();
20
+ }
21
+
22
+ public function testDatacenterResolutionAndPriorScore(): void
23
+ {
24
+ $hetzner = $this->engine->lookup('95.216.12.34');
25
+ $this->assertSame('HOSTING', $hetzner->getType());
26
+ $this->assertSame(55.0, $hetzner->getBaseScore());
27
+ $this->assertSame(0.85, $hetzner->getInflectionPoint());
28
+ $this->assertFalse($hetzner->isToleranceRotation());
29
+
30
+ $aws = $this->engine->lookup('54.210.1.20');
31
+ $this->assertSame('HOSTING', $aws->getType());
32
+ $this->assertSame(55.0, $aws->getBaseScore());
33
+ }
34
+
35
+ public function testMobileCgnatResolution(): void
36
+ {
37
+ $cgnat = $this->engine->lookup('100.70.1.25');
38
+ $this->assertSame('CELLULAR', $cgnat->getType());
39
+ $this->assertSame(10.0, $cgnat->getBaseScore());
40
+ $this->assertSame(0.60, $cgnat->getInflectionPoint());
41
+ $this->assertTrue($cgnat->isToleranceRotation());
42
+ }
43
+
44
+ public function testStarlinkSatelliteResolution(): void
45
+ {
46
+ $starlink = $this->engine->lookup('98.97.10.5');
47
+ $this->assertSame('SATELLITE', $starlink->getType());
48
+ $this->assertSame(15.0, $starlink->getBaseScore());
49
+ $this->assertSame(150.0, $starlink->getJitterTolerance());
50
+ }
51
+
52
+ public function testAnalogInconsistencyModulation(): void
53
+ {
54
+ $consistency = 0.80; // Incohérence légère de rendu
55
+ $scoreDatacenter = RequestUtils::calculateAnalogInconsistencyScore($consistency, NetworkProfile::hosting());
56
+ $scoreMobile = RequestUtils::calculateAnalogInconsistencyScore($consistency, NetworkProfile::cellular());
57
+
58
+ $this->assertGreaterThan(55.0, $scoreDatacenter, 'Sur Datacenter (0.80 < 0.85), la suspicion décolle au-dessus de 55.0');
59
+ $this->assertLessThan(10.0, $scoreMobile, 'Sur Mobile (0.80 > 0.60), la variation est entièrement absorbée (< 10.0)');
60
+ }
61
+ }
@@ -303,11 +303,12 @@ class FingerprintEngineTest extends TestCase
303
303
  $this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
304
304
  }
305
305
 
306
- $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
306
+ $configDir = sys_get_temp_dir() . '/anonympins-test-keys-' . time();
307
307
  if (!is_dir($configDir)) {
308
308
  mkdir($configDir, 0777, true);
309
309
  }
310
310
  $keyPath = $configDir . '/ed25519_key.json';
311
+ Env::set('FINGERPRINT_KEY_DIR', $configDir);
311
312
 
312
313
  Env::clear('ED25519_PRIVATE_KEY');
313
314
  Env::clear('ED25519_PUBLIC_KEY');
@@ -338,11 +339,12 @@ class FingerprintEngineTest extends TestCase
338
339
 
339
340
  public function testEd25519KeyLoadingFromDisk(): void
340
341
  {
341
- $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
342
+ $configDir = sys_get_temp_dir() . '/anonympins-test-keys-' . time();
342
343
  if (!is_dir($configDir)) {
343
344
  mkdir($configDir, 0777, true);
344
345
  }
345
346
  $keyPath = $configDir . '/ed25519_key.json';
347
+ Env::set('FINGERPRINT_KEY_DIR', $configDir);
346
348
 
347
349
  $dummyKeys = [
348
350
  'privateKey' => '-----BEGIN PRIVATE KEY-----\ndummy-php-private\n-----END PRIVATE KEY-----',
@@ -21,10 +21,15 @@ class Env
21
21
  }
22
22
  // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Environment and server variables may contain raw cryptographic keys.
23
23
  if (isset($_ENV[$key]) && $_ENV[$key] !== '') {
24
- return $_ENV[$key];
24
+ $envVal = function_exists('wp_unslash') ? wp_unslash($_ENV[$key]) : $_ENV[$key];
25
+ return is_string($envVal) ? (function_exists('sanitize_textarea_field') ? sanitize_textarea_field($envVal) : $envVal) : $envVal;
25
26
  }
26
27
  if (isset($_SERVER[$key]) && $_SERVER[$key] !== '') {
27
- return $_SERVER[$key];
28
+ $serverVal = function_exists('wp_unslash') ? wp_unslash($_SERVER[$key]) : $_SERVER[$key];
29
+ if (is_string($serverVal)) {
30
+ return function_exists('sanitize_textarea_field') ? sanitize_textarea_field($serverVal) : $serverVal;
31
+ }
32
+ return $serverVal;
28
33
  }
29
34
  // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
30
35
  $val = getenv($key);
@@ -9,6 +9,8 @@ use Anonympins\Fingerprint\FingerprintBuilder;
9
9
  use Anonympins\Fingerprint\Optimization\Optimization;
10
10
  use Anonympins\Fingerprint\RequestContext;
11
11
  use Anonympins\Fingerprint\Store\StoreManager;
12
+ use Anonympins\Fingerprint\AsnLookupEngine;
13
+ use Anonympins\Fingerprint\NetworkProfile;
12
14
 
13
15
  /**
14
16
  * Classe utilitaire pour l'analyse des requêtes et le calcul des scores de suspicion.
@@ -314,6 +316,7 @@ class RequestUtils
314
316
 
315
317
  $claimedBrowserInfo = self::parseUserAgent($ua);
316
318
  $claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
319
+ $tlsSpoofingScore = 0.0;
317
320
 
318
321
  if (empty($claimedBrowser) || empty($ua)) {
319
322
  return ['tlsSpoofingScore' => 0.0];
@@ -365,7 +368,7 @@ class RequestUtils
365
368
  $echExpected = $context->getHeader('x-ech-expected') === 'true' || !empty($outerSni);
366
369
 
367
370
  if (!empty($outerSni) && !empty($host) && strcasecmp($outerSni, $host) !== 0 && !$hasEch) {
368
- return ['tlsSpoofingScore' => max($tlsSpoofingScore ?? 0.0, 60.0)];
371
+ return ['tlsSpoofingScore' => max($tlsSpoofingScore, 60.0)];
369
372
  }
370
373
 
371
374
  $browserVer = (int)($claimedBrowserInfo['version'] ?? 0);
@@ -374,7 +377,7 @@ class RequestUtils
374
377
  ($claimedBrowser === 'Firefox' && $browserVer >= 118)
375
378
  );
376
379
  if ($context->isHttps && $isModernEchBrowser && $echExpected && !$hasEch) {
377
- return ['tlsSpoofingScore' => max($tlsSpoofingScore ?? 0.0, 55.0)];
380
+ return ['tlsSpoofingScore' => max($tlsSpoofingScore, 55.0)];
378
381
  }
379
382
 
380
383
  return ['tlsSpoofingScore' => 0.0];
@@ -1021,7 +1024,7 @@ class RequestUtils
1021
1024
  * @param array<string, mixed> $deviceData
1022
1025
  * @return array{'historyScore': float, 'rotationScore': float}
1023
1026
  */
1024
- public static function getBehavioralIndicators(RequestContext $context, array &$deviceData): array
1027
+ public static function getBehavioralIndicators(RequestContext $context, array &$deviceData, ?NetworkProfile $netProfile = null): array
1025
1028
  {
1026
1029
  $now = time() * 1000;
1027
1030
  $clientIp = $context->clientIp;
@@ -1089,6 +1092,11 @@ class RequestUtils
1089
1092
  $maxIpsPerDevice = 15;
1090
1093
  $freeIpChanges = 3;
1091
1094
  $historyScore = min(100.0, (max(0, count($deviceData['ips']) - $freeIpChanges) / $maxIpsPerDevice) * 100);
1095
+
1096
+ // Mobile CGNAT : désactivation/amortissement de la pénalité de rotation rapide d'IP
1097
+ if ($netProfile !== null && $netProfile->isToleranceRotation()) {
1098
+ $historyScore = min($historyScore, 10.0);
1099
+ }
1092
1100
  // Score de rotation basé sur les changements rapides de fingerprint
1093
1101
  $rotationScore = min(100.0, (($deviceData['rapidChangeCount'] ?? 0) / $maxRapidChanges) * 100);
1094
1102
 
@@ -1858,18 +1866,20 @@ class RequestUtils
1858
1866
  */
1859
1867
  public static function getIpReputationScore(string $ip): float
1860
1868
  {
1869
+ $profile = AsnLookupEngine::getInstance()->lookup($ip);
1870
+ $baseScore = $profile->getBaseScore();
1861
1871
  $store = StoreManager::getStore();
1862
1872
  $key = "ip-reputation:{$ip}";
1863
1873
  $data = $store->get($key);
1864
1874
  if ($data === null) {
1865
- return 0.0;
1875
+ return $baseScore;
1866
1876
  }
1867
1877
 
1868
1878
  $now = time();
1869
1879
  $hoursPassed = ($now - $data['lastUpdate']) / 3600;
1870
1880
  $decay = (int)floor($hoursPassed * 2); // Décroissance de 2 points par heure
1871
1881
 
1872
- return (float)max(0.0, $data['score'] - $decay);
1882
+ return (float)max($baseScore, $data['score'] - $decay);
1873
1883
  }
1874
1884
 
1875
1885
  /**
@@ -2693,13 +2703,21 @@ class RequestUtils
2693
2703
  /**
2694
2704
  * Calcule un score d'incohérence analogique et lisse plafonnant à 99.9 max.
2695
2705
  */
2696
- public static function calculateAnalogInconsistencyScore(float $consistencyScore, float $inflectionPoint = 0.72, float $steepness = 12.0): float
2706
+ public static function calculateAnalogInconsistencyScore(float $consistencyScore, $inflectionOrProfile = 0.72, float $steepness = 12.0): float
2697
2707
  {
2698
2708
  $s = max(0.0, min(1.0, $consistencyScore));
2699
2709
  if ($s >= 0.98) {
2700
2710
  return 0.0;
2701
2711
  }
2702
2712
 
2713
+ if ($inflectionOrProfile instanceof NetworkProfile) {
2714
+ $inflectionPoint = $inflectionOrProfile->getInflectionPoint();
2715
+ } elseif (is_array($inflectionOrProfile)) {
2716
+ $inflectionPoint = (float)($inflectionOrProfile['inflectionPoint'] ?? $inflectionOrProfile['inflection_point'] ?? 0.72);
2717
+ } else {
2718
+ $inflectionPoint = (float)$inflectionOrProfile;
2719
+ }
2720
+
2703
2721
  $asymptote = 99.9;
2704
2722
  $raw = 1.0 / (1.0 + exp($steepness * ($s - $inflectionPoint)));
2705
2723
  $minVal = 1.0 / (1.0 + exp($steepness * (1.0 - $inflectionPoint)));
@@ -156,4 +156,31 @@ class WpDbStore implements IStore
156
156
  );
157
157
  return is_numeric($deleted) ? (int)$deleted : 0;
158
158
  }
159
+
160
+ /**
161
+ * Returns the total number of records currently stored.
162
+ */
163
+ public function getTotalCount(): int
164
+ {
165
+ if (!$this->db || !method_exists($this->db, 'get_var')) {
166
+ return 0;
167
+ }
168
+ $count = $this->db->get_var("SELECT COUNT(*) FROM `{$this->table}`");
169
+ return is_numeric($count) ? (int)$count : 0;
170
+ }
171
+
172
+ /**
173
+ * Returns the number of expired records awaiting cron deletion.
174
+ */
175
+ public function getExpiredCount(): int
176
+ {
177
+ if (!$this->db || !method_exists($this->db, 'get_var') || !method_exists($this->db, 'prepare')) {
178
+ return 0;
179
+ }
180
+ $now = time();
181
+ $count = $this->db->get_var(
182
+ $this->db->prepare("SELECT COUNT(*) FROM `{$this->table}` WHERE expires_at IS NOT NULL AND expires_at < %d", $now)
183
+ );
184
+ return is_numeric($count) ? (int)$count : 0;
185
+ }
159
186
  }