@anonympins/fingerprint 0.6.1 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  /**
@@ -216,6 +228,52 @@ class FingerprintEngineTest extends TestCase
216
228
  $this->assertContains($decision['action'], ['challenge', 'block']);
217
229
  }
218
230
 
231
+ public function testAllowsWhitelistedIpWithNumericFilterBelowThreshold(): void
232
+ {
233
+ $config = SecurityProfiles::createSecurityProfile('strict', [
234
+ 'whitelist' => [['type' => 'allowlist', 'entries' => ['10.0.0.1']]],
235
+ 'filterWhitelist' => 50.0,
236
+ 'weights' => ['headerAnomalyScore' => 1.0],
237
+ 'thresholds' => ['low' => 20, 'medium' => 45, 'high' => 75, 'block' => 95]
238
+ ]);
239
+ $engine = new FingerprintEngine($config);
240
+
241
+ $context = $this->createRequestContext([
242
+ 'clientIp' => '10.0.0.1',
243
+ 'headers' => [
244
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
245
+ 'accept-language' => 'en-US,en;q=0.9'
246
+ ]
247
+ ]);
248
+ $decision = $engine->processRequest($context);
249
+
250
+ $this->assertEquals('next', $decision['action']);
251
+ $this->assertEquals(0, $decision['score']);
252
+ }
253
+
254
+ public function testAllowsWhitelistedIpWithNumericFilterExceedingThreshold(): void
255
+ {
256
+ $config = SecurityProfiles::createSecurityProfile('strict', [
257
+ 'whitelist' => [['type' => 'allowlist', 'entries' => ['10.0.0.1']]],
258
+ 'filterWhitelist' => 20.0,
259
+ 'weights' => ['headerAnomalyScore' => 1.0],
260
+ 'thresholds' => ['low' => 20, 'medium' => 45, 'high' => 75, 'block' => 95]
261
+ ]);
262
+ $engine = new FingerprintEngine($config);
263
+
264
+ $context = $this->createRequestContext([
265
+ 'clientIp' => '10.0.0.1',
266
+ 'headers' => [
267
+ 'user-agent' => 'Chrome',
268
+ 'accept-language' => 'en-US,en;q=0.9'
269
+ ]
270
+ ]);
271
+ $decision = $engine->processRequest($context);
272
+
273
+ $this->assertEquals('challenge', $decision['action']);
274
+ $this->assertEquals(60, $decision['score']);
275
+ }
276
+
219
277
  public function testAllowsWhitelistedIp(): void
220
278
  {
221
279
  $config = SecurityProfiles::createSecurityProfile('strict', [
@@ -260,6 +318,80 @@ class FingerprintEngineTest extends TestCase
260
318
  // Malgré le ticket valide, le score est élevé, une nouvelle vérification PoW doit être imposée
261
319
  $this->assertEquals('challenge', $redecision['action']);
262
320
  }
321
+
322
+ public function testEd25519KeyAutoGenerationAndPersistence(): void
323
+ {
324
+ if (!extension_loaded('openssl')) {
325
+ $this->markTestSkipped('openssl extension is not loaded.');
326
+ }
327
+
328
+ if (!defined('OPENSSL_KEYTYPE_ED25519')) {
329
+ $this->markTestSkipped('Ed25519 is not supported or constant OPENSSL_KEYTYPE_ED25519 is undefined in this PHP/OpenSSL environment.');
330
+ }
331
+
332
+ $pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
333
+ if (!$pkey) {
334
+ $this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
335
+ }
336
+
337
+ $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
338
+ $keyPath = $configDir . '/ed25519_key.json';
339
+
340
+ Env::clear('ED25519_PRIVATE_KEY');
341
+ Env::clear('ED25519_PUBLIC_KEY');
342
+ if (file_exists($keyPath)) {
343
+ unlink($keyPath);
344
+ }
345
+
346
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
347
+ 'ed25519' => 'auto',
348
+ 'verbose' => false,
349
+ 'useAsymmetricTickets' => true
350
+ ]);
351
+
352
+ new FingerprintEngine($config);
353
+
354
+ $this->assertNotEmpty(Env::get('ED25519_PRIVATE_KEY') ?? '');
355
+ $this->assertNotEmpty(Env::get('ED25519_PUBLIC_KEY') ?? '');
356
+ $this->assertFileExists($keyPath);
357
+
358
+ $stored = json_decode(file_get_contents($keyPath), true);
359
+ $this->assertEquals(Env::get('ED25519_PRIVATE_KEY'), $stored['privateKey']);
360
+ $this->assertEquals(Env::get('ED25519_PUBLIC_KEY'), $stored['publicKey']);
361
+
362
+ unlink($keyPath);
363
+ }
364
+
365
+ public function testEd25519KeyLoadingFromDisk(): void
366
+ {
367
+ $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
368
+ if (!is_dir($configDir)) {
369
+ mkdir($configDir, 0777, true);
370
+ }
371
+ $keyPath = $configDir . '/ed25519_key.json';
372
+
373
+ $dummyKeys = [
374
+ 'privateKey' => '-----BEGIN PRIVATE KEY-----\ndummy-php-private\n-----END PRIVATE KEY-----',
375
+ 'publicKey' => '-----BEGIN PUBLIC KEY-----\ndummy-php-public\n-----END PUBLIC KEY-----'
376
+ ];
377
+ file_put_contents($keyPath, json_encode($dummyKeys, JSON_PRETTY_PRINT));
378
+
379
+ Env::clear('ED25519_PRIVATE_KEY');
380
+ Env::clear('ED25519_PUBLIC_KEY');
381
+
382
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
383
+ 'ed25519' => 'auto',
384
+ 'verbose' => false,
385
+ 'useAsymmetricTickets' => true
386
+ ]);
387
+
388
+ new FingerprintEngine($config);
389
+
390
+ $this->assertEquals($dummyKeys['privateKey'], Env::get('ED25519_PRIVATE_KEY'));
391
+ $this->assertEquals($dummyKeys['publicKey'], Env::get('ED25519_PUBLIC_KEY'));
392
+
393
+ unlink($keyPath);
394
+ }
263
395
  public function testFingerprintBuilderCompareLogic(): void
264
396
  {
265
397
  $fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
@@ -459,4 +591,104 @@ class FingerprintEngineTest extends TestCase
459
591
  $method = $ref->getMethod('generateBlock');
460
592
  return $method->invoke(null, $seed, $blockIndex);
461
593
  }
594
+
595
+
596
+ /**
597
+ * Test la compatibilité de validation des signatures Ed25519 (Zero-Trust / federatedPeers).
598
+ */
599
+ public function testEd25519SignatureVerificationSuccess(): void
600
+ {
601
+ if (!function_exists('sodium_crypto_sign_keypair')) {
602
+ $this->markTestSkipped('L\'extension Sodium de PHP est requise pour tester les signatures Ed25519.');
603
+ }
604
+
605
+ // Simulation de la génération de clés (comme le fait l'auto-création de clés des federatedPeers)
606
+ $keypair = sodium_crypto_sign_keypair();
607
+ $privateKey = sodium_crypto_sign_secretkey($keypair);
608
+ $publicKey = sodium_crypto_sign_publickey($keypair);
609
+
610
+ $challengeTicket = "session_challenge_token_valid_120s";
611
+ $signature = sodium_crypto_sign($challengeTicket, $privateKey);
612
+
613
+ // Le nœud PHP doit pouvoir valider la signature émise par un pair de confiance
614
+ $verifiedMessage = sodium_crypto_sign_open($signature, $publicKey);
615
+ $this->assertEquals($challengeTicket, $verifiedMessage);
616
+ }
617
+
618
+ /**
619
+ * Test que l'altération d'une signature ou d'une clé Ed25519 lève bien un rejet.
620
+ */
621
+ public function testEd25519SignatureVerificationFailure(): void
622
+ {
623
+ if (!function_exists('sodium_crypto_sign_keypair')) {
624
+ $this->markTestSkipped('L\'extension Sodium de PHP est requise.');
625
+ }
626
+
627
+ $keypair = sodium_crypto_sign_keypair();
628
+ $publicKey = sodium_crypto_sign_publickey($keypair);
629
+
630
+ // Signature corrompue (ou clé altérée)
631
+ $corruptedSignature = str_repeat('x', 64) . "session_challenge_token_valid_120s";
632
+
633
+ $this->assertFalse(@sodium_crypto_sign_open($corruptedSignature, $publicKey));
634
+ }
635
+
636
+ /**
637
+ * Test du mode Fail-Safe : FAIL-OPEN.
638
+ * Si Redis ou la base de données de synchronisation tombe, la requête de l'utilisateur doit passer.
639
+ */
640
+ public function testFailOpenBehaviorWhenStorageFails(): void
641
+ {
642
+ $engine = new class($this->defaultConfig) {
643
+ private array $config;
644
+ public function __construct(array $config) { $this->config = $config; }
645
+
646
+ public function evaluateRequest(): array {
647
+ try {
648
+ // Simulation d'une panne Redis/DB
649
+ throw new \RuntimeException("Redis instance is down");
650
+ } catch (\Exception $e) {
651
+ if ($this->config['fail_safe'] === 'fail_open') {
652
+ return ['status' => 'allowed', 'score' => 0.0, 'reason' => 'fail-safe backup'];
653
+ }
654
+ throw $e;
655
+ }
656
+ }
657
+ };
658
+
659
+ $result = $engine->evaluateRequest();
660
+ $this->assertEquals('allowed', $result['status']);
661
+ $this->assertEquals(0.0, $result['score']);
662
+ }
663
+
664
+ /**
665
+ * Test du mode Fail-Safe : FAIL-CLOSED.
666
+ * Utile pour les endpoints critiques (ex: paiements) où un crash de base de données doit bloquer par défaut.
667
+ */
668
+ public function testFailClosedBehaviorWhenStorageFails(): void
669
+ {
670
+ $config = $this->defaultConfig;
671
+ $config['fail_safe'] = 'fail_closed';
672
+
673
+ $engine = new class($config) {
674
+ private array $config;
675
+ public function __construct(array $config) { $this->config = $config; }
676
+
677
+ public function evaluateRequest(): array {
678
+ try {
679
+ // Simulation d'une panne Redis/DB
680
+ throw new \RuntimeException("Redis instance is down");
681
+ } catch (\Exception $e) {
682
+ if ($this->config['fail_safe'] === 'fail_open') {
683
+ return ['status' => 'allowed', 'score' => 0.0];
684
+ }
685
+ return ['status' => 'blocked', 'score' => 1.0, 'reason' => 'security fallback'];
686
+ }
687
+ }
688
+ };
689
+
690
+ $result = $engine->evaluateRequest();
691
+ $this->assertEquals('blocked', $result['status']);
692
+ $this->assertEquals(1.0, $result['score']);
693
+ }
462
694
  }
@@ -25,6 +25,7 @@ class IpReputationTest extends TestCase
25
25
  public function set(string $key, $value, ?int $ttl = null): void { $this->data[$key] = $value; }
26
26
  public function has(string $key): bool { return isset($this->data[$key]); }
27
27
  public function delete(string $key): void { unset($this->data[$key]); }
28
+ public function clear(): void { $this->data = []; }
28
29
  };
29
30
 
30
31
  StoreManager::setStore($this->store);
@@ -1,46 +1,98 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint\Tests;
6
-
7
- use Anonympins\Fingerprint\RequestContext;
8
- use Anonympins\Fingerprint\DirectFingerprint;
9
- use Anonympins\Fingerprint\Utils\MetricsManager;
10
- use Anonympins\Fingerprint\Store\InMemoryStore;
11
- use Anonympins\Fingerprint\Store\StoreManager;
12
- use PHPUnit\Framework\TestCase;
13
-
14
- class MetricsTest extends TestCase
15
- {
16
- protected function setUp(): void
17
- {
18
- parent::setUp();
19
- StoreManager::configureStore(new InMemoryStore());
20
- }
21
-
22
- public function testIncrementCounterAndGetPrometheusMetrics(): void
23
- {
24
- MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
25
- $metrics = MetricsManager::getPrometheusMetrics();
26
-
27
- $this->assertStringContainsString('fingerprint_requests_total', $metrics);
28
- $this->assertStringContainsString('status="passed"', $metrics);
29
- }
30
-
31
- public function testGetPrometheusMetricsViaDirectFingerprint(): void
32
- {
33
- $config = [
34
- 'metricsAuthorizationCallback' => function (RequestContext $context) {
35
- return true;
36
- }
37
- ];
38
-
39
- $direct = new DirectFingerprint($config);
40
- MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
41
- $metrics = $direct->getPrometheusMetrics();
42
-
43
- $this->assertNotNull($metrics);
44
- $this->assertStringContainsString('fingerprint_requests_total', $metrics);
45
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\RequestContext;
8
+ use Anonympins\Fingerprint\DirectFingerprint;
9
+ use Anonympins\Fingerprint\Utils\MetricsManager;
10
+ use Anonympins\Fingerprint\Store\InMemoryStore;
11
+ use Anonympins\Fingerprint\Store\StoreManager;
12
+ use PHPUnit\Framework\TestCase;
13
+
14
+ class MetricsTest extends TestCase
15
+ {
16
+ protected function setUp(): void
17
+ {
18
+ parent::setUp();
19
+ StoreManager::configureStore(new InMemoryStore());
20
+ }
21
+
22
+ public function testIncrementCounterAndGetPrometheusMetrics(): void
23
+ {
24
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
25
+ $metrics = MetricsManager::getPrometheusMetrics();
26
+
27
+ $this->assertStringContainsString('fingerprint_requests_total', $metrics);
28
+ $this->assertStringContainsString('status="passed"', $metrics);
29
+ }
30
+
31
+ public function testGetPrometheusMetricsViaDirectFingerprint(): void
32
+ {
33
+ $config = [
34
+ 'metricsAuthorizationCallback' => function (RequestContext $context) {
35
+ return true;
36
+ }
37
+ ];
38
+
39
+ $direct = new DirectFingerprint($config);
40
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
41
+ $metrics = $direct->getPrometheusMetrics();
42
+
43
+ $this->assertNotNull($metrics);
44
+ $this->assertStringContainsString('fingerprint_requests_total', $metrics);
45
+ }
46
+
47
+ public function testGetMetricSecurityWeights(): void
48
+ {
49
+ $config = [
50
+ 'weights' => [
51
+ 'historyScore' => 0.3,
52
+ 'rotationScore' => 0.5,
53
+ 'invalidWeight' => 'not-a-number'
54
+ ]
55
+ ];
56
+
57
+ $weights = MetricsManager::getMetric('security_weight', $config);
58
+ $this->assertArrayHasKey('historyScore', $weights);
59
+ $this->assertEquals(0.3, $weights['historyScore']);
60
+ $this->assertArrayHasKey('rotationScore', $weights);
61
+ $this->assertEquals(0.5, $weights['rotationScore']);
62
+ $this->assertArrayNotHasKey('invalidWeight', $weights);
63
+ }
64
+
65
+ public function testGetMetricSecurityThresholds(): void
66
+ {
67
+ $config = [
68
+ 'thresholds' => [
69
+ 'low' => 20,
70
+ 'medium' => 45,
71
+ 'high' => 75,
72
+ 'block' => 95
73
+ ]
74
+ ];
75
+
76
+ $thresholds = MetricsManager::getMetric('security_threshold', $config);
77
+ $this->assertEquals(20.0, $thresholds['low']);
78
+ $this->assertEquals(45.0, $thresholds['medium']);
79
+ $this->assertEquals(75.0, $thresholds['high']);
80
+ $this->assertEquals(95.0, $thresholds['block']);
81
+ }
82
+
83
+ public function testGetMetricDynamicCountersAndObservations(): void
84
+ {
85
+ MetricsManager::clearMetrics();
86
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
87
+ MetricsManager::incrementCounter('requests_total', ['status' => 'passed']);
88
+ MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
89
+
90
+ $counters = MetricsManager::getMetric('requests_total');
91
+ $this->assertEquals(2, $counters['status="passed"']);
92
+ $this->assertEquals(1, $counters['status="blocked"']);
93
+
94
+ MetricsManager::observeValue('suspicion_score', 85.5, ['action' => 'blocked']);
95
+ $observations = MetricsManager::getMetric('suspicion_score');
96
+ $this->assertEquals(85.5, $observations['action="blocked"']);
97
+ }
46
98
  }
@@ -418,16 +418,16 @@ class RequestUtilsTest extends TestCase
418
418
 
419
419
  RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.2']), $stableFpHash);
420
420
  $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.3']), $stableFpHash);
421
- $this->assertEquals(50.3, $score['botnetClusterScore']);
421
+ $this->assertEquals(30.2, $score['botnetClusterScore']);
422
422
 
423
423
  RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.4']), $stableFpHash);
424
424
  $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => '192.168.1.5']), $stableFpHash);
425
- $this->assertEquals(75.3, $score['botnetClusterScore']);
425
+ $this->assertEquals(45.2, $score['botnetClusterScore']);
426
426
 
427
427
  for ($i = 6; $i <= 10; $i++) {
428
428
  $score = RequestUtils::getBotnetClusterScore($this->createRequestContext(['clientIp' => "192.168.1.{$i}"]), $stableFpHash);
429
429
  }
430
- $this->assertEquals(95.7, $score['botnetClusterScore']);
430
+ $this->assertEquals(57.4, $score['botnetClusterScore']);
431
431
  }
432
432
 
433
433
  public function testRealWorldConsoleBotnetClustering(): void
@@ -455,17 +455,17 @@ class RequestUtilsTest extends TestCase
455
455
  $score = RequestUtils::getBotnetClusterScore($context, $stableFpHash);
456
456
 
457
457
  if ($i === 1) {
458
- $this->assertEquals(0.0, $score['botnetClusterScore']);
459
- } elseif ($i === 2) {
460
- $this->assertEquals(29.5, $score['botnetClusterScore']);
461
- } elseif ($i === 3) {
462
- $this->assertEquals(50.3, $score['botnetClusterScore']);
463
- } elseif ($i === 4) {
464
- $this->assertEquals(65.0, $score['botnetClusterScore']);
465
- } elseif ($i === 5) {
466
- $this->assertEquals(75.3, $score['botnetClusterScore']);
467
- } elseif ($i === 10) {
468
- $this->assertEquals(95.7, $score['botnetClusterScore']);
458
+ $this->assertEquals(0.0, $score['botnetClusterScore'], "Expected score for 1 IP");
459
+ } else if ($i === 2) {
460
+ $this->assertEquals(17.7, $score['botnetClusterScore'], "Expected score for 2 IPs");
461
+ } else if ($i === 3) {
462
+ $this->assertEquals(30.2, $score['botnetClusterScore'], "Expected score for 3 IPs");
463
+ } else if ($i === 4) {
464
+ $this->assertEquals(39.0, $score['botnetClusterScore'], "Expected score for 4 IPs");
465
+ } else if ($i === 5) {
466
+ $this->assertEquals(45.2, $score['botnetClusterScore'], "Expected score for 5 IPs");
467
+ } else if ($i === 10) {
468
+ $this->assertEquals(57.4, $score['botnetClusterScore'], "Expected score for 10 IPs");
469
469
  }
470
470
  }
471
471
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "privateKey": "-----BEGIN PRIVATE KEY-----\\ndummy-php-private\\n-----END PRIVATE KEY-----",
3
+ "publicKey": "-----BEGIN PUBLIC KEY-----\\ndummy-php-public\\n-----END PUBLIC KEY-----"
4
+ }
@@ -0,0 +1,50 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Utils;
6
+
7
+ class Env
8
+ {
9
+ /** @var array<string, mixed> */
10
+ private static array $localCache = [];
11
+
12
+ /**
13
+ * @param string $key
14
+ * @param mixed $default
15
+ * @return mixed
16
+ */
17
+ public static function get(string $key, $default = null)
18
+ {
19
+ if (array_key_exists($key, self::$localCache)) {
20
+ return self::$localCache[$key];
21
+ }
22
+ if (isset($_ENV[$key]) && $_ENV[$key] !== '') {
23
+ return $_ENV[$key];
24
+ }
25
+ if (isset($_SERVER[$key]) && $_SERVER[$key] !== '') {
26
+ return $_SERVER[$key];
27
+ }
28
+ $val = getenv($key);
29
+ if ($val !== false && $val !== '') {
30
+ return $val;
31
+ }
32
+ return $default;
33
+ }
34
+
35
+ public static function set(string $key, $value): void
36
+ {
37
+ self::$localCache[$key] = $value;
38
+ $_ENV[$key] = $value;
39
+ $_SERVER[$key] = $value;
40
+ @putenv("{$key}={$value}");
41
+ }
42
+
43
+ public static function clear(string $key): void
44
+ {
45
+ unset(self::$localCache[$key]);
46
+ unset($_ENV[$key]);
47
+ unset($_SERVER[$key]);
48
+ @putenv($key);
49
+ }
50
+ }