@anonympins/fingerprint 0.6.0 → 0.6.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.
@@ -11,6 +11,7 @@
11
11
  use Anonympins\Fingerprint\Utils\Logger;
12
12
  use Anonympins\Fingerprint\Utils\MetricsManager;
13
13
  use Anonympins\Fingerprint\Utils\RequestUtils;
14
+ use Anonympins\Fingerprint\Utils\Env;
14
15
 
15
16
  // Correction de l'import
16
17
 
@@ -164,14 +165,56 @@
164
165
 
165
166
  public function __construct(array $securityConfig)
166
167
  {
167
- $this->isProduction = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'production';
168
+ $this->isProduction = Env::get('APP_ENV') === 'production';
168
169
 
169
170
  // Dynamically bind Ed25519 keys if passed via config
170
171
  if (isset($securityConfig['ed25519_private_key'])) {
171
- $_ENV['ED25519_PRIVATE_KEY'] = $securityConfig['ed25519_private_key'];
172
+ Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
172
173
  }
173
174
  if (isset($securityConfig['ed25519_public_key'])) {
174
- $_ENV['ED25519_PUBLIC_KEY'] = $securityConfig['ed25519_public_key'];
175
+ Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
176
+ }
177
+
178
+ // Génération et persistance automatique de la paire de clés Ed25519
179
+ $useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
180
+ $envPrivate = Env::get('ED25519_PRIVATE_KEY');
181
+ if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
182
+ $configDir = dirname(__DIR__, 1) . '/config';
183
+ $persistentKeyPath = $configDir . '/ed25519_key.json';
184
+
185
+ if (file_exists($persistentKeyPath)) {
186
+ try {
187
+ $keys = json_decode(file_get_contents($persistentKeyPath), true);
188
+ if (isset($keys['privateKey'], $keys['publicKey'])) {
189
+ Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
190
+ Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
191
+ }
192
+ } catch (\Throwable $e) {
193
+ error_log('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
194
+ }
195
+ } else {
196
+ try {
197
+ if (defined('OPENSSL_KEYTYPE_ED25519')) {
198
+ $pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
199
+ if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
200
+ $details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
201
+ $publicKeyPem = $details['key']; // @phpstan-ignore-line
202
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
203
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
204
+ // Also update $_ENV and $_SERVER for consistency
205
+ if (!is_dir($configDir)) {
206
+ mkdir($configDir, 0777, true);
207
+ }
208
+ file_put_contents($persistentKeyPath, json_encode([
209
+ 'privateKey' => $privateKeyPem,
210
+ 'publicKey' => $publicKeyPem
211
+ ], JSON_PRETTY_PRINT));
212
+ }
213
+ }
214
+ } catch (\Throwable $e) {
215
+ error_log('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
216
+ }
217
+ }
175
218
  }
176
219
 
177
220
  // Auto-load optimized config if autotuning savePath is specified
@@ -222,7 +265,8 @@
222
265
  'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
223
266
  'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
224
267
  'similarityThreshold', 'summary', 'description',
225
- 'ed25519_private_key', 'ed25519_public_key'
268
+ 'ed25519_private_key', 'ed25519_public_key',
269
+ 'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret'
226
270
  ];
227
271
 
228
272
  if (empty($config['weights'])) {
@@ -483,6 +527,9 @@
483
527
  */
484
528
  private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
485
529
  {
530
+ if ($context->resolvedIdentity !== null) {
531
+ return $context->resolvedIdentity;
532
+ }
486
533
  $this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
487
534
  $store = StoreManager::getStore();
488
535
  $existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
@@ -564,11 +611,12 @@
564
611
  $store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
565
612
  }
566
613
 
567
- return [
614
+ $context->resolvedIdentity = [
568
615
  'deviceId' => $deviceId,
569
616
  'deviceData' => $deviceData,
570
617
  'newCookie' => $newCookie,
571
618
  ];
619
+ return $context->resolvedIdentity;
572
620
  }
573
621
 
574
622
  /**
@@ -662,13 +710,13 @@
662
710
  }
663
711
 
664
712
  // Check 4: La stagnation (Lack of Entropy / Genericity)
665
- if ($uaParts['browser']) {
713
+ if (is_string($uaParts['browser'])) {
666
714
  $ja4Key = "ja4-browsers:{$ja4}";
667
715
  $seenBrowsers = $store->get($ja4Key) ?: [];
668
716
  if (!is_array($seenBrowsers)) {
669
717
  $seenBrowsers = [];
670
718
  }
671
- $browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
719
+ $browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
672
720
  if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
673
721
  $seenBrowsers[] = $browserFamily;
674
722
  $store->set($ja4Key, $seenBrowsers, 86400);
@@ -874,7 +922,7 @@
874
922
 
875
923
  $coopOp = $context->query['coop_op'] ?? null;
876
924
  if ($coopOp) {
877
- $result = ChallengeUtils::handleCooperativeRequest($context->query);
925
+ $result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
878
926
  return [
879
927
  'action' => 'challenge',
880
928
  'status' => 200,
@@ -891,22 +939,11 @@
891
939
  $context->graphqlOperation = $gqlInfo;
892
940
  }
893
941
  }
894
-
895
-
896
- $this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
897
-
898
- // Parse GraphQL query if applicable
899
- if ($context->path === '/graphql' && !empty($context->body)) {
900
- $gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
901
- if ($gqlInfo) {
902
- $context->graphqlOperation = $gqlInfo;
903
- }
904
- }
905
942
 
906
943
  // 1. Vérifier les listes blanches
907
944
  if ($this->checkAllowlists($context)) {
945
+ MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
908
946
  return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
909
- MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
910
947
  }
911
948
 
912
949
  // Initialiser le vecteur de suspicion
@@ -1125,6 +1162,24 @@
1125
1162
  MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
1126
1163
  $this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
1127
1164
  }
1165
+
1166
+ // Federated Threat Intelligence & ZKP Synchronization
1167
+ $zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
1168
+ $parts = explode(':', $zkpProof);
1169
+ if (count($parts) === 3) {
1170
+ $zkpY = $parts[0];
1171
+ $zkpT = $parts[1];
1172
+ $zkpS = $parts[2];
1173
+ // 1. Valider cryptographiquement la preuve avant de bannir/diffuser
1174
+ if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
1175
+ // 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
1176
+ if (!$store->has("banned-zkp-y:{$zkpY}")) {
1177
+ $store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
1178
+ $this->broadcastBannedZkp($zkpY);
1179
+ }
1180
+ }
1181
+ }
1182
+
1128
1183
  $decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
1129
1184
  if ($this->dryRun) {
1130
1185
  $this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
@@ -1216,7 +1271,7 @@
1216
1271
 
1217
1272
  if ($shouldUseUsefulWork) {
1218
1273
  $this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
1219
- $defaultPath = dirname(__DIR__, 2) . '/config/problems.config.json';
1274
+ $defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
1220
1275
  $configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
1221
1276
  $problemManager = ProblemManager::getInstance($configPath, $store);
1222
1277
  $work = $problemManager->dispatchWork($finalScore);
@@ -1407,6 +1462,84 @@
1407
1462
  return false;
1408
1463
  }
1409
1464
 
1465
+
1466
+ private function broadcastBannedZkp(string $zkpY): void
1467
+ {
1468
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
1469
+ if (empty($peers)) return;
1470
+
1471
+ $timestamp = (int)(microtime(true) * 1000);
1472
+ $msg = "{$timestamp}:{$zkpY}";
1473
+
1474
+ $signature = '';
1475
+ $isAsymmetric = false;
1476
+
1477
+ $privateKey = Env::get('ED25519_PRIVATE_KEY');
1478
+ if ($privateKey) {
1479
+ try {
1480
+ $cleanKey = str_replace('\n', "\n", $privateKey);
1481
+ $pkeyObj = openssl_pkey_get_private($cleanKey);
1482
+ if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
1483
+ $signature = bin2hex($sigBytes);
1484
+ $isAsymmetric = true;
1485
+ }
1486
+ } catch (\Throwable $e) {
1487
+ error_log('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
1488
+ }
1489
+ }
1490
+
1491
+ if (!$isAsymmetric) {
1492
+ $secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
1493
+ $signature = hash_hmac('sha256', $msg, $secret);
1494
+ }
1495
+
1496
+ foreach ($peers as $peerUrl) {
1497
+ $this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
1498
+ 'zkpY' => $zkpY,
1499
+ 'signature' => $isAsymmetric ? '' : $signature,
1500
+ 'signature_ed25519' => $isAsymmetric ? $signature : '',
1501
+ 'timestamp' => $timestamp
1502
+ ]);
1503
+ }
1504
+ }
1505
+
1506
+ private function asyncPost(string $url, array $params): void
1507
+ {
1508
+ $parts = parse_url($url);
1509
+ if ($parts === false) return;
1510
+
1511
+ $host = $parts['host'];
1512
+ $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
1513
+ $path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
1514
+ $scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
1515
+
1516
+ $postData = json_encode($params);
1517
+
1518
+ $fp = @stream_socket_client(
1519
+ "{$scheme}{$host}:{$port}",
1520
+ $errno,
1521
+ $errstr,
1522
+ 0.5,
1523
+ STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
1524
+ );
1525
+
1526
+ if ($fp) {
1527
+ stream_set_blocking($fp, false);
1528
+ $out = "POST {$path} HTTP/1.1\r\n";
1529
+ $out .= "Host: {$host}\r\n";
1530
+ $out .= "Content-Type: application/json\r\n";
1531
+ $out .= "Content-Length: " . strlen($postData) . "\r\n";
1532
+ $out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
1533
+ $out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
1534
+ $out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
1535
+ $out .= "Connection: Close\r\n\r\n";
1536
+ $out .= $postData;
1537
+
1538
+ @fwrite($fp, $out);
1539
+ @fclose($fp);
1540
+ }
1541
+ }
1542
+
1410
1543
  /**
1411
1544
  * Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
1412
1545
  * en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
@@ -129,6 +129,7 @@ class OptimizationOperators
129
129
  */
130
130
  public static function solveFullSecurityTuning(array $context, array $options = []): array
131
131
  {
132
+ $currentConfig = $context['currentConfig'] ?? null;
132
133
  $fitnessFunction = self::createFullSecurityConfigEvaluator($context);
133
134
 
134
135
  $crossover = function (array $c1, array $c2): array {
@@ -143,31 +144,30 @@ class OptimizationOperators
143
144
  return $child;
144
145
  };
145
146
  $createIndividual = function () use ($currentConfig): array {
146
- if ($currentConfig) {
147
- $ind = [
148
- 'thresholds' => [],
149
- 'weights' => [],
150
- 'patterns' => []
151
- ];
152
- foreach (['thresholds', 'weights', 'patterns'] as $section) {
153
- if (isset($currentConfig[$section]) && is_array($currentConfig[$section])) {
154
- foreach ($currentConfig[$section] as $k => $v) {
155
- if (is_numeric($v) && $k !== 'honeypotScore') {
156
- $randomVariation = 1.0 + (self::secureRandom() - 0.5) * 0.5; // Variation de +/- 25%
157
- $ind[$section][$k] = $v * $randomVariation;
158
- } else {
159
- $ind[$section][$k] = $v;
160
- }
147
+ $config = $currentConfig ?: \Anonympins\Fingerprint\Config\SecurityProfiles::createSecurityProfile('balanced');
148
+ $ind = [
149
+ 'thresholds' => [],
150
+ 'weights' => [],
151
+ 'patterns' => []
152
+ ];
153
+ foreach (['thresholds', 'weights', 'patterns'] as $section) {
154
+ if (isset($config[$section]) && is_array($config[$section])) {
155
+ foreach ($config[$section] as $k => $v) {
156
+ if (is_numeric($v) && $k !== 'honeypotScore') {
157
+ $randomVariation = 1.0 + (self::secureRandom() - 0.5) * 0.5; // Variation de +/- 25%
158
+ $ind[$section][$k] = $v * $randomVariation;
159
+ } else {
160
+ $ind[$section][$k] = $v;
161
161
  }
162
162
  }
163
163
  }
164
- if (isset($ind['thresholds']['low'], $ind['thresholds']['medium'], $ind['thresholds']['high'])) {
165
- $ind['thresholds']['low'] = max(10.0, min(35.0, (float)$ind['thresholds']['low']));
166
- $ind['thresholds']['medium'] = max($ind['thresholds']['low'] + 5.0, min(70.0, (float)$ind['thresholds']['medium']));
167
- $ind['thresholds']['high'] = max($ind['thresholds']['medium'] + 5.0, min(90.0, (float)$ind['thresholds']['high']));
168
- }
169
- return $ind;
170
164
  }
165
+ if (isset($ind['thresholds']['low'], $ind['thresholds']['medium'], $ind['thresholds']['high'])) {
166
+ $ind['thresholds']['low'] = max(10.0, min(35.0, (float)$ind['thresholds']['low']));
167
+ $ind['thresholds']['medium'] = max($ind['thresholds']['low'] + 5.0, min(70.0, (float)$ind['thresholds']['medium']));
168
+ $ind['thresholds']['high'] = max($ind['thresholds']['medium'] + 5.0, min(90.0, (float)$ind['thresholds']['high']));
169
+ }
170
+ return $ind;
171
171
  };
172
172
 
173
173
  $mutate = function (array $c, ?array $currentConfigRef = null) use ($currentConfig): array {
@@ -32,6 +32,9 @@ class RequestContext
32
32
  /** @var ?array<string, mixed> */
33
33
  public ?array $newCookieForResponse = null;
34
34
 
35
+ /** @var ?array<string, mixed> */
36
+ public ?array $resolvedIdentity = null;
37
+
35
38
  // Propriétés spécifiques qui peuvent être fournies par un proxy inverse
36
39
  public ?string $ja3 = null;
37
40
  public ?string $ja4 = null;
@@ -40,6 +43,7 @@ class RequestContext
40
43
  public ?string $http2Fingerprint = null;
41
44
  public ?string $quicFingerprint = null;
42
45
  public ?string $tcpFingerprint = null;
46
+ public ?string $zkpY = null; // NOUVEAU: Clé publique ZKP du client
43
47
 
44
48
  /**
45
49
  * @param string $clientIp
@@ -79,6 +83,7 @@ class RequestContext
79
83
  $this->http2Fingerprint = $this->headers['x-http2-fingerprint'] ?? null;
80
84
  $this->tcpFingerprint = $this->headers['x-tcp-fingerprint'] ?? null;
81
85
  $this->quicFingerprint = $this->headers['x-quic-fp'] ?? null;
86
+ $this->zkpY = isset($this->headers['x-zkp-proof']) ? explode(':', $this->headers['x-zkp-proof'])[0] : null;
82
87
  $this->tlsSessionId = $this->headers['x-tls-session-id'] ?? $this->headers['x-ssl-session-id'] ?? null;
83
88
  }
84
89
  /**
@@ -8,6 +8,7 @@ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
8
  use Anonympins\Fingerprint\Store\InMemoryStore;
9
9
  use Anonympins\Fingerprint\Store\StoreManager;
10
10
  use Anonympins\Fingerprint\Utils\BigInt;
11
+ use Anonympins\Fingerprint\Utils\Env;
11
12
  use PHPUnit\Framework\TestCase;
12
13
 
13
14
  class ChallengeUtilsTest extends TestCase
@@ -94,7 +95,7 @@ class ChallengeUtilsTest extends TestCase
94
95
  // Définir une clé secrète pour les tests
95
96
  $store = new InMemoryStore();
96
97
  StoreManager::configureStore($store);
97
- $_ENV['POW_SECRET'] = 'test-secret-key-that-is-long-enough-for-hmac';
98
+ Env::set('POW_SECRET', 'test-secret-key-that-is-long-enough-for-hmac');
98
99
  }
99
100
 
100
101
  public function testZkpProofValidation(): void
@@ -219,8 +220,8 @@ class ChallengeUtilsTest extends TestCase
219
220
  $this->markTestSkipped('Ed25519 signing is not supported due to PHP 8.0.0 openssl_sign() null algorithm bug.');
220
221
  }
221
222
 
222
- $_ENV['ED25519_PRIVATE_KEY'] = $privateKeyPem;
223
- $_ENV['ED25519_PUBLIC_KEY'] = $publicKeyPem;
223
+ Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
224
+ Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
224
225
 
225
226
  $ip = '127.0.0.1';
226
227
  $expiry = (int)floor(microtime(true) * 1000) + 3600000;
@@ -237,7 +238,8 @@ class ChallengeUtilsTest extends TestCase
237
238
  $this->assertTrue(ChallengeUtils::isTicketValid($ip, $ticket, 'device-123', 'hash-abc'));
238
239
  $this->assertFalse(ChallengeUtils::isTicketValid('192.168.1.1', $ticket, 'device-123', 'hash-abc'));
239
240
 
240
- unset($_ENV['ED25519_PRIVATE_KEY'], $_ENV['ED25519_PUBLIC_KEY']);
241
+ Env::clear('ED25519_PRIVATE_KEY');
242
+ Env::clear('ED25519_PUBLIC_KEY');
241
243
  }
242
244
 
243
245
  public function testCooperativePoSpaceWorkflow(): void
@@ -11,10 +11,13 @@ use Anonympins\Fingerprint\RequestContext;
11
11
  use Anonympins\Fingerprint\Store\InMemoryStore;
12
12
  use Anonympins\Fingerprint\Store\StoreManager;
13
13
  use Anonympins\Fingerprint\Challenge\ChallengeUtils;
14
+ use Anonympins\Fingerprint\Utils\Env;
14
15
  use PHPUnit\Framework\TestCase;
15
16
 
16
17
  class FingerprintEngineTest extends TestCase
17
18
  {
19
+ private array $defaultConfig;
20
+
18
21
  private FingerprintEngine $engine;
19
22
  private array $securityConfig;
20
23
 
@@ -24,9 +27,17 @@ class FingerprintEngineTest extends TestCase
24
27
  */
25
28
  protected function setUp(): void
26
29
  {
30
+ $this->defaultConfig = [
31
+ 'fail_safe' => 'fail_open', // 'fail_open' ou 'fail_closed'
32
+ 'threshold' => 0.7,
33
+ 'shared_keys' => []
34
+ ];
35
+
27
36
  // 1. Utiliser un store en mémoire propre pour chaque test
28
37
  $store = new InMemoryStore();
29
38
  StoreManager::configureStore($store);
39
+ Env::clear('ED25519_PRIVATE_KEY');
40
+ Env::clear('ED25519_PUBLIC_KEY');
30
41
 
31
42
  // 2. Charger une configuration de sécurité de base pour les tests
32
43
  $this->securityConfig = SecurityProfiles::createSecurityProfile('balanced', [
@@ -36,6 +47,7 @@ class FingerprintEngineTest extends TestCase
36
47
 
37
48
  // 3. Créer une nouvelle instance du moteur pour chaque test
38
49
  $this->engine = new FingerprintEngine($this->securityConfig);
50
+
39
51
  }
40
52
 
41
53
  /**
@@ -260,6 +272,80 @@ class FingerprintEngineTest extends TestCase
260
272
  // Malgré le ticket valide, le score est élevé, une nouvelle vérification PoW doit être imposée
261
273
  $this->assertEquals('challenge', $redecision['action']);
262
274
  }
275
+
276
+ public function testEd25519KeyAutoGenerationAndPersistence(): void
277
+ {
278
+ if (!extension_loaded('openssl')) {
279
+ $this->markTestSkipped('openssl extension is not loaded.');
280
+ }
281
+
282
+ if (!defined('OPENSSL_KEYTYPE_ED25519')) {
283
+ $this->markTestSkipped('Ed25519 is not supported or constant OPENSSL_KEYTYPE_ED25519 is undefined in this PHP/OpenSSL environment.');
284
+ }
285
+
286
+ $pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
287
+ if (!$pkey) {
288
+ $this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
289
+ }
290
+
291
+ $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
292
+ $keyPath = $configDir . '/ed25519_key.json';
293
+
294
+ Env::clear('ED25519_PRIVATE_KEY');
295
+ Env::clear('ED25519_PUBLIC_KEY');
296
+ if (file_exists($keyPath)) {
297
+ unlink($keyPath);
298
+ }
299
+
300
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
301
+ 'ed25519' => 'auto',
302
+ 'verbose' => false,
303
+ 'useAsymmetricTickets' => true
304
+ ]);
305
+
306
+ new FingerprintEngine($config);
307
+
308
+ $this->assertNotEmpty(Env::get('ED25519_PRIVATE_KEY') ?? '');
309
+ $this->assertNotEmpty(Env::get('ED25519_PUBLIC_KEY') ?? '');
310
+ $this->assertFileExists($keyPath);
311
+
312
+ $stored = json_decode(file_get_contents($keyPath), true);
313
+ $this->assertEquals(Env::get('ED25519_PRIVATE_KEY'), $stored['privateKey']);
314
+ $this->assertEquals(Env::get('ED25519_PUBLIC_KEY'), $stored['publicKey']);
315
+
316
+ unlink($keyPath);
317
+ }
318
+
319
+ public function testEd25519KeyLoadingFromDisk(): void
320
+ {
321
+ $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
322
+ if (!is_dir($configDir)) {
323
+ mkdir($configDir, 0777, true);
324
+ }
325
+ $keyPath = $configDir . '/ed25519_key.json';
326
+
327
+ $dummyKeys = [
328
+ 'privateKey' => '-----BEGIN PRIVATE KEY-----\ndummy-php-private\n-----END PRIVATE KEY-----',
329
+ 'publicKey' => '-----BEGIN PUBLIC KEY-----\ndummy-php-public\n-----END PUBLIC KEY-----'
330
+ ];
331
+ file_put_contents($keyPath, json_encode($dummyKeys, JSON_PRETTY_PRINT));
332
+
333
+ Env::clear('ED25519_PRIVATE_KEY');
334
+ Env::clear('ED25519_PUBLIC_KEY');
335
+
336
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
337
+ 'ed25519' => 'auto',
338
+ 'verbose' => false,
339
+ 'useAsymmetricTickets' => true
340
+ ]);
341
+
342
+ new FingerprintEngine($config);
343
+
344
+ $this->assertEquals($dummyKeys['privateKey'], Env::get('ED25519_PRIVATE_KEY'));
345
+ $this->assertEquals($dummyKeys['publicKey'], Env::get('ED25519_PUBLIC_KEY'));
346
+
347
+ unlink($keyPath);
348
+ }
263
349
  public function testFingerprintBuilderCompareLogic(): void
264
350
  {
265
351
  $fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
@@ -459,4 +545,104 @@ class FingerprintEngineTest extends TestCase
459
545
  $method = $ref->getMethod('generateBlock');
460
546
  return $method->invoke(null, $seed, $blockIndex);
461
547
  }
548
+
549
+
550
+ /**
551
+ * Test la compatibilité de validation des signatures Ed25519 (Zero-Trust / federatedPeers).
552
+ */
553
+ public function testEd25519SignatureVerificationSuccess(): void
554
+ {
555
+ if (!function_exists('sodium_crypto_sign_keypair')) {
556
+ $this->markTestSkipped('L\'extension Sodium de PHP est requise pour tester les signatures Ed25519.');
557
+ }
558
+
559
+ // Simulation de la génération de clés (comme le fait l'auto-création de clés des federatedPeers)
560
+ $keypair = sodium_crypto_sign_keypair();
561
+ $privateKey = sodium_crypto_sign_secretkey($keypair);
562
+ $publicKey = sodium_crypto_sign_publickey($keypair);
563
+
564
+ $challengeTicket = "session_challenge_token_valid_120s";
565
+ $signature = sodium_crypto_sign($challengeTicket, $privateKey);
566
+
567
+ // Le nœud PHP doit pouvoir valider la signature émise par un pair de confiance
568
+ $verifiedMessage = sodium_crypto_sign_open($signature, $publicKey);
569
+ $this->assertEquals($challengeTicket, $verifiedMessage);
570
+ }
571
+
572
+ /**
573
+ * Test que l'altération d'une signature ou d'une clé Ed25519 lève bien un rejet.
574
+ */
575
+ public function testEd25519SignatureVerificationFailure(): void
576
+ {
577
+ if (!function_exists('sodium_crypto_sign_keypair')) {
578
+ $this->markTestSkipped('L\'extension Sodium de PHP est requise.');
579
+ }
580
+
581
+ $keypair = sodium_crypto_sign_keypair();
582
+ $publicKey = sodium_crypto_sign_publickey($keypair);
583
+
584
+ // Signature corrompue (ou clé altérée)
585
+ $corruptedSignature = str_repeat('x', 64) . "session_challenge_token_valid_120s";
586
+
587
+ $this->assertFalse(@sodium_crypto_sign_open($corruptedSignature, $publicKey));
588
+ }
589
+
590
+ /**
591
+ * Test du mode Fail-Safe : FAIL-OPEN.
592
+ * Si Redis ou la base de données de synchronisation tombe, la requête de l'utilisateur doit passer.
593
+ */
594
+ public function testFailOpenBehaviorWhenStorageFails(): void
595
+ {
596
+ $engine = new class($this->defaultConfig) {
597
+ private array $config;
598
+ public function __construct(array $config) { $this->config = $config; }
599
+
600
+ public function evaluateRequest(): array {
601
+ try {
602
+ // Simulation d'une panne Redis/DB
603
+ throw new \RuntimeException("Redis instance is down");
604
+ } catch (\Exception $e) {
605
+ if ($this->config['fail_safe'] === 'fail_open') {
606
+ return ['status' => 'allowed', 'score' => 0.0, 'reason' => 'fail-safe backup'];
607
+ }
608
+ throw $e;
609
+ }
610
+ }
611
+ };
612
+
613
+ $result = $engine->evaluateRequest();
614
+ $this->assertEquals('allowed', $result['status']);
615
+ $this->assertEquals(0.0, $result['score']);
616
+ }
617
+
618
+ /**
619
+ * Test du mode Fail-Safe : FAIL-CLOSED.
620
+ * Utile pour les endpoints critiques (ex: paiements) où un crash de base de données doit bloquer par défaut.
621
+ */
622
+ public function testFailClosedBehaviorWhenStorageFails(): void
623
+ {
624
+ $config = $this->defaultConfig;
625
+ $config['fail_safe'] = 'fail_closed';
626
+
627
+ $engine = new class($config) {
628
+ private array $config;
629
+ public function __construct(array $config) { $this->config = $config; }
630
+
631
+ public function evaluateRequest(): array {
632
+ try {
633
+ // Simulation d'une panne Redis/DB
634
+ throw new \RuntimeException("Redis instance is down");
635
+ } catch (\Exception $e) {
636
+ if ($this->config['fail_safe'] === 'fail_open') {
637
+ return ['status' => 'allowed', 'score' => 0.0];
638
+ }
639
+ return ['status' => 'blocked', 'score' => 1.0, 'reason' => 'security fallback'];
640
+ }
641
+ }
642
+ };
643
+
644
+ $result = $engine->evaluateRequest();
645
+ $this->assertEquals('blocked', $result['status']);
646
+ $this->assertEquals(1.0, $result['score']);
647
+ }
462
648
  }