@anonympins/fingerprint 0.7.1 → 0.7.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.
@@ -1,663 +1,701 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint\Tests;
6
-
7
- use Anonympins\Fingerprint\Config\SecurityProfiles;
8
- use Anonympins\Fingerprint\FingerprintBuilder;
9
- use Anonympins\Fingerprint\FingerprintEngine;
10
- use Anonympins\Fingerprint\RequestContext;
11
- use Anonympins\Fingerprint\Store\InMemoryStore;
12
- use Anonympins\Fingerprint\Store\StoreManager;
13
- use Anonympins\Fingerprint\Challenge\ChallengeUtils;
14
- use Anonympins\Fingerprint\Utils\Env;
15
- use PHPUnit\Framework\TestCase;
16
-
17
- class FingerprintEngineTest extends TestCase
18
- {
19
- private array $defaultConfig;
20
-
21
- private FingerprintEngine $engine;
22
- private array $securityConfig;
23
-
24
- /**
25
- * Cette méthode est appelée avant chaque test.
26
- * Elle garantit que chaque test s'exécute dans un environnement propre.
27
- */
28
- protected function setUp(): void
29
- {
30
- $this->defaultConfig = [
31
- 'fail_safe' => 'fail_open', // 'fail_open' ou 'fail_closed'
32
- 'threshold' => 0.7,
33
- 'shared_keys' => []
34
- ];
35
-
36
- // 1. Utiliser un store en mémoire propre pour chaque test
37
- $store = new InMemoryStore();
38
- StoreManager::configureStore($store);
39
- Env::clear('ED25519_PRIVATE_KEY');
40
- Env::clear('ED25519_PUBLIC_KEY');
41
-
42
- // 2. Charger une configuration de sécurité de base pour les tests
43
- $this->securityConfig = SecurityProfiles::createSecurityProfile('balanced', [
44
- 'verbose' => false, // Désactiver les logs pour ne pas polluer la sortie des tests
45
- 'challengeNewDevices' => false, // Simplifie les tests de base
46
- ]);
47
-
48
- // 3. Créer une nouvelle instance du moteur pour chaque test
49
- $this->engine = new FingerprintEngine($this->securityConfig);
50
-
51
- }
52
-
53
- /**
54
- * Crée un contexte de requête de base pour les tests.
55
- *
56
- * @param array<string, mixed> $overrides
57
- * @return RequestContext
58
- */
59
- private function createRequestContext(array $overrides = []): RequestContext
60
- {
61
- $defaults = [
62
- 'clientIp' => '192.168.1.10',
63
- 'path' => '/',
64
- 'headers' => [
65
- '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',
66
- 'accept-language' => 'en-US,en;q=0.9',
67
- ],
68
- 'query' => [],
69
- 'body' => null,
70
- 'cookies' => [],
71
- 'httpVersion' => '1.1',
72
- ];
73
-
74
- $params = array_merge($defaults, $overrides);
75
-
76
- return new RequestContext(
77
- $params['clientIp'],
78
- $params['path'],
79
- $params['headers'],
80
- $params['query'],
81
- $params['body'],
82
- $params['cookies'],
83
- $params['httpVersion']
84
- );
85
- }
86
-
87
- public function testAllowsLegitimateRequest(): void
88
- {
89
- $context = $this->createRequestContext();
90
-
91
- // Simule une première visite
92
- $firstDecision = $this->engine->processRequest($context);
93
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "Un nouveau cookie aurait dû être généré.");
94
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
95
-
96
- // Simule une deuxième visite avec le cookie reçu
97
- $nextContext = $this->createRequestContext([
98
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']]
99
- ]);
100
- $secondDecision = $this->engine->processRequest($nextContext);
101
-
102
- $this->assertEquals('next', $secondDecision['action']);
103
- $this->assertLessThan($this->securityConfig['thresholds']['low'], $secondDecision['score']);
104
- }
105
-
106
- public function testTlsSessionResumptionCookielessTracking(): void
107
- {
108
- // 1. Première requête d'un client avec un ID de session TLS, sans cookie
109
- $tlsSessionId = 'test-tls-1.3-session-resumption-id-12345';
110
- $context1 = $this->createRequestContext([
111
- 'headers' => [
112
- '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',
113
- 'x-tls-session-id' => $tlsSessionId,
114
- ]
115
- ]);
116
-
117
- $firstDecision = $this->engine->processRequest($context1);
118
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "Un cookie d'identité d'appareil doit être généré.");
119
- $deviceId = $firstDecision['newCookieForResponse']['value'];
120
-
121
- // 2. Deuxième requête : le client supprime ses cookies, mais garde la même session TLS (Session Resumption / 0-RTT)
122
- $context2 = $this->createRequestContext([
123
- 'headers' => [
124
- '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',
125
- 'x-tls-session-id' => $tlsSessionId,
126
- ],
127
- 'cookies' => [] // Cookies supprimés !
128
- ]);
129
-
130
- $secondDecision = $this->engine->processRequest($context2);
131
- $this->assertEquals('next', $secondDecision['action']);
132
- $this->assertArrayNotHasKey('newCookieForResponse', $secondDecision, "Aucun nouveau cookie d'identité ne doit être généré car la session TLS a permis de restaurer l'identité.");
133
- }
134
-
135
- public function testIssuesChallengeForSuspiciousRequest(): void
136
- {
137
- // 1. First, establish a device identity to avoid the cookie-dropping penalty
138
- $firstContext = $this->createRequestContext();
139
- $firstDecision = $this->engine->processRequest($firstContext);
140
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "A device ID cookie should have been generated.");
141
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
142
-
143
- // 2. Now, simulate a suspicious request from that same device (e.g., missing user-agent)
144
- $suspiciousContext = $this->createRequestContext([
145
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
146
- 'headers' => [
147
- // No user-agent, which is anomalous
148
- 'accept-language' => 'en-US,en;q=0.9',
149
- ]
150
- ]);
151
-
152
- $decision = $this->engine->processRequest($suspiciousContext);
153
-
154
- $this->assertEquals('challenge', $decision['action'], "The action should be to issue a challenge.");
155
- $this->assertGreaterThanOrEqual($this->securityConfig['thresholds']['low'], $decision['score'], "The score should be above the 'low' threshold.");
156
- $this->assertLessThan($this->securityConfig['thresholds']['block'], $decision['score']);
157
- }
158
-
159
- public function testBlocksHighlySuspiciousRequest(): void
160
- {
161
- // Configure the engine to recognize the 'email_confirm' honeypot field.
162
- // We need to create a new engine instance for this test with a custom config.
163
- $config = SecurityProfiles::createSecurityProfile('balanced', [
164
- 'honeypot' => ['fields' => ['email_confirm']],
165
- 'verbose' => true,
166
- 'weights'=> ['honeypotScore' => 1]
167
- ]);
168
- $engine = new FingerprintEngine($config);
169
-
170
- // 1. First, establish a device identity to avoid other scores interfering.
171
- $firstContext = $this->createRequestContext();
172
- $firstDecision = $engine->processRequest($firstContext);
173
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision);
174
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
175
-
176
- // 2. Now, simulate a request from that device that falls into a honeypot trap.
177
- $honeypotContext = $this->createRequestContext([
178
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
179
- // The bot filled in a hidden field, triggering the honeypot.
180
- 'body' => ['email_confirm' => 'bot@example.com']
181
- ]);
182
-
183
- $decision = $engine->processRequest($honeypotContext);
184
-
185
- $this->assertEquals('block', $decision['action']);
186
- $this->assertGreaterThanOrEqual($config['thresholds']['block'], $decision['score']);
187
- }
188
-
189
- public function testDetectsCookieDropping(): void
190
- {
191
- $context = $this->createRequestContext(['clientIp' => '1.2.3.4']);
192
-
193
- // 1. Première requête, un "pending_cookie" est défini pour cette IP
194
- $this->engine->processRequest($context);
195
-
196
- // 2. Deuxième requête de la même IP, mais sans le cookie attendu
197
- $contextWithoutCookie = $this->createRequestContext(['clientIp' => '1.2.3.4']);
198
- $decision = $this->engine->processRequest($contextWithoutCookie);
199
-
200
- // Le score de "cookieDropping" devrait être de 100
201
- $this->assertEquals(100, $decision['vector']['cookieDroppingScore']);
202
- // L'action devrait être un challenge car le score final sera élevé
203
- $this->assertEquals('challenge', $decision['action']);
204
- }
205
-
206
- public function testDetectsFingerprintInconsistency(): void
207
- {
208
- $context = $this->createRequestContext();
209
-
210
- // 1. Première visite, le fingerprint initial est stocké
211
- $firstDecision = $this->engine->processRequest($context);
212
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
213
-
214
- // 2. Deuxième visite avec le même cookie, mais un User-Agent complètement différent (incohérence)
215
- $nextContext = $this->createRequestContext([
216
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
217
- 'headers' => [
218
- 'user-agent' => 'DefinitelyNotTheSameBrowser/1.0',
219
- 'accept-language' => 'fr-FR',
220
- ]
221
- ]);
222
-
223
- $decision = $this->engine->processRequest($nextContext);
224
-
225
- // Le score d'incohérence plafonne à l'asymptote de 99.9
226
- $this->assertEqualsWithDelta(99.9, $decision['vector']['inconsistencyScore'], 0.1);
227
- // L'action devrait être un challenge ou un blocage
228
- $this->assertContains($decision['action'], ['challenge', 'block']);
229
- }
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
-
277
- public function testAllowsWhitelistedIp(): void
278
- {
279
- $config = SecurityProfiles::createSecurityProfile('strict', [
280
- 'whitelist' => [['type' => 'allowlist', 'entries' => ['10.0.0.1']]]
281
- ]);
282
- $engine = new FingerprintEngine($config);
283
-
284
- $context = $this->createRequestContext(['clientIp' => '10.0.0.1']);
285
- $decision = $engine->processRequest($context);
286
-
287
- $this->assertEquals('next', $decision['action']);
288
- $this->assertEquals(0, $decision['score']);
289
- }
290
-
291
- public function testEd25519KeyAutoGenerationAndPersistence(): void
292
- {
293
- if (!extension_loaded('openssl')) {
294
- $this->markTestSkipped('openssl extension is not loaded.');
295
- }
296
-
297
- if (!defined('OPENSSL_KEYTYPE_ED25519')) {
298
- $this->markTestSkipped('Ed25519 is not supported or constant OPENSSL_KEYTYPE_ED25519 is undefined in this PHP/OpenSSL environment.');
299
- }
300
-
301
- $pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
302
- if (!$pkey) {
303
- $this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
304
- }
305
-
306
- $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
307
- $keyPath = $configDir . '/ed25519_key.json';
308
-
309
- Env::clear('ED25519_PRIVATE_KEY');
310
- Env::clear('ED25519_PUBLIC_KEY');
311
- if (file_exists($keyPath)) {
312
- unlink($keyPath);
313
- }
314
-
315
- $config = SecurityProfiles::createSecurityProfile('balanced', [
316
- 'ed25519' => 'auto',
317
- 'verbose' => false,
318
- 'useAsymmetricTickets' => true
319
- ]);
320
-
321
- new FingerprintEngine($config);
322
-
323
- $this->assertNotEmpty(Env::get('ED25519_PRIVATE_KEY') ?? '');
324
- $this->assertNotEmpty(Env::get('ED25519_PUBLIC_KEY') ?? '');
325
- $this->assertFileExists($keyPath);
326
-
327
- $stored = json_decode(file_get_contents($keyPath), true);
328
- $this->assertEquals(Env::get('ED25519_PRIVATE_KEY'), $stored['privateKey']);
329
- $this->assertEquals(Env::get('ED25519_PUBLIC_KEY'), $stored['publicKey']);
330
-
331
- unlink($keyPath);
332
- }
333
-
334
- public function testEd25519KeyLoadingFromDisk(): void
335
- {
336
- $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
337
- if (!is_dir($configDir)) {
338
- mkdir($configDir, 0777, true);
339
- }
340
- $keyPath = $configDir . '/ed25519_key.json';
341
-
342
- $dummyKeys = [
343
- 'privateKey' => '-----BEGIN PRIVATE KEY-----\ndummy-php-private\n-----END PRIVATE KEY-----',
344
- 'publicKey' => '-----BEGIN PUBLIC KEY-----\ndummy-php-public\n-----END PUBLIC KEY-----'
345
- ];
346
- file_put_contents($keyPath, json_encode($dummyKeys, JSON_PRETTY_PRINT));
347
-
348
- Env::clear('ED25519_PRIVATE_KEY');
349
- Env::clear('ED25519_PUBLIC_KEY');
350
-
351
- $config = SecurityProfiles::createSecurityProfile('balanced', [
352
- 'ed25519' => 'auto',
353
- 'verbose' => false,
354
- 'useAsymmetricTickets' => true
355
- ]);
356
-
357
- new FingerprintEngine($config);
358
-
359
- $this->assertEquals($dummyKeys['privateKey'], Env::get('ED25519_PRIVATE_KEY'));
360
- $this->assertEquals($dummyKeys['publicKey'], Env::get('ED25519_PUBLIC_KEY'));
361
-
362
- unlink($keyPath);
363
- }
364
- public function testFingerprintBuilderCompareLogic(): void
365
- {
366
- $fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
367
- $fp2 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
368
- $fp3 = (new FingerprintBuilder())->add('hw', '4_8')->add('gpu', 'amd')->__toString(); // hw:1039882088834313|gpu:14339535343484648
369
- $fp4 = (new FingerprintBuilder())->add('hw', '8_16')->add('os', 'win32')->__toString(); // hw:1039882088834313|os:14339535343484648
370
-
371
- $this->assertEquals(1.0, FingerprintBuilder::compare($fp1, $fp2), "Identical FPs should return 1.0");
372
- $this->assertLessThan(0.5, FingerprintBuilder::compare($fp1, $fp3), "Different FPs should have low similarity");
373
-
374
- // The partial match score calculation reflects the current weights in FingerprintBuilder.
375
- // Matching keys: 'hw' (weight 1.5).
376
- // All relevant keys considered from both fingerprints: 'hw' (1.5), 'gpu' (4.0), 'os' (0.8).
377
- // Total weight = 1.5 + 4.0 + 0.8 = 6.3.
378
- // Score = 1.5 / 6.3 = ~0.238095... (This logic is correct, the test is fine)
379
- $this->assertEqualsWithDelta(0.238, FingerprintBuilder::compare($fp1, $fp4), 0.001, "Partial match score should reflect current weights"); // This test is correct, no change needed.
380
- }
381
-
382
- /**
383
- * @dataProvider clientHintsInconsistencyProvider
384
- * @param array<string, string> $headers Les en-têtes à tester.
385
- * @param float $expectedScore Le score attendu.
386
- * @param string $message Le message de test.
387
- */
388
- public function testDetectsClientHintsInconsistency(array $headers, float $expectedScore, string $message): void
389
- {
390
- // Le test nécessite un deviceId, donc on simule une première visite.
391
- // Les en-têtes de la première visite n'ont pas d'importance ici.
392
- $firstDecision = $this->engine->processRequest($this->createRequestContext());
393
- $deviceIdCookie = $firstDecision['newCookieForResponse'] ?? null;
394
- $this->assertNotNull($deviceIdCookie, "Un cookie deviceId aurait dû être créé.");
395
-
396
- // On simule la visite suivante avec les en-têtes à tester.
397
- $testContext = $this->createRequestContext([
398
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
399
- 'headers' => $headers,
400
- ]);
401
-
402
- $decision = $this->engine->processRequest($testContext);
403
- $this->assertEquals($expectedScore, $decision['vector']['clientHintsInconsistencyScore'], $message);
404
- }
405
-
406
- /**
407
- * Fournisseur de données pour le test d'incohérence des Client Hints.
408
- * @return array<string, array{0: array<string, string>, 1: float, 2: string}>
409
- */
410
- public function clientHintsInconsistencyProvider(): array
411
- {
412
- $baseHeaders = ['accept-language' => 'en-US,en;q=0.9'];
413
-
414
- return [
415
- 'no inconsistency' => [
416
- array_merge($baseHeaders, [
417
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
418
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
419
- ]),
420
- 0.0,
421
- 'Should return 0 for consistent headers.'
422
- ],
423
- 'large version mismatch' => [
424
- array_merge($baseHeaders, [
425
- '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',
426
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
427
- ]),
428
- 96.7,
429
- 'Should return 80 for a large version mismatch (>5).'
430
- ],
431
- 'small version mismatch' => [
432
- array_merge($baseHeaders, [
433
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
434
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
435
- ]),
436
- 40.0,
437
- 'Should return 40 for a small version mismatch (>1).'
438
- ],
439
- 'browser mismatch' => [
440
- array_merge($baseHeaders, [
441
- 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
442
- 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
443
- ]),
444
- 90.0,
445
- 'Should return 90 for a browser mismatch (Firefox vs Chrome).'
446
- ],
447
- 'missing client hints header' => [
448
- ['user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0'],
449
- 0.0,
450
- 'Should return 0 if sec-ch-ua header is missing.'
451
- ],
452
- 'malformed client hints' => [
453
- array_merge($baseHeaders, [
454
- 'user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0',
455
- 'sec-ch-ua' => '"SomeOtherBrowser";v="abc"',
456
- ]),
457
- 0.0,
458
- 'Should return 0 if headers are malformed or unparsable.'
459
- ],
460
- ];
461
- }
462
-
463
- public function testDetectsTcpAnomalyScore(): void
464
- {
465
- $firstContext = $this->createRequestContext();
466
- $firstDecision = $this->engine->processRequest($firstContext);
467
- $this->assertArrayHasKey('newCookieForResponse', $firstDecision);
468
- $deviceIdCookie = $firstDecision['newCookieForResponse'];
469
-
470
- // Trame binaire TCP SYN Linux simulée (TTL=64, WS=7)
471
- $linuxSynHex = '4500003c1a2b400040063c1a7f0000017f0000011f9000500000000100000000a00272103c1a0000020405b4040201030307';
472
-
473
- $context = $this->createRequestContext([
474
- 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
475
- 'headers' => [
476
- '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',
477
- 'accept-language' => 'en-US,en;q=0.9',
478
- 'x-raw-tcp-binary' => $linuxSynHex
479
- ]
480
- ]);
481
-
482
- $decision = $this->engine->processRequest($context);
483
-
484
- $this->assertEquals(80.1, $decision['vector']['tcpAnomalyScore']);
485
- // Le poids de tcpAnomalyScore dans le profil balanced est de 0.8 (80 * 0.8 = 64)
486
- $this->assertGreaterThanOrEqual(64.0, $decision['score']);
487
- }
488
-
489
- public function testDetectsAndVerifiesSpaceChallenge(): void
490
- {
491
- $store = StoreManager::getStore();
492
-
493
- $config = SecurityProfiles::createSecurityProfile('balanced', [
494
- 'enableProofOfSpace' => true,
495
- 'pospace' => [
496
- 'sizeMb' => 1,
497
- 'numQueries' => 5
498
- ],
499
- 'challengeNewDevices' => false,
500
- ]);
501
-
502
- $engine = $this->getMockBuilder(FingerprintEngine::class)
503
- ->setConstructorArgs([$config])
504
- ->onlyMethods(['calculateFinalScore'])
505
- ->getMock();
506
- $engine->method('calculateFinalScore')->willReturn(30.0);
507
-
508
- $headers = [
509
- 'user-agent' => 'A normal browser',
510
- 'accept-language' => 'en-US,en;q=0.9',
511
- ];
512
-
513
- // 1. Première requête provoquant un challenge
514
- $context = $this->createRequestContext([
515
- 'headers' => $headers
516
- ]);
517
-
518
- $decision = $engine->processRequest($context);
519
- $this->assertEquals('challenge', $decision['action']);
520
-
521
- $html = $decision['body'];
522
- $this->assertStringContainsString('initializeSpace', $html);
523
-
524
- preg_match('/const nonce = "(.*?)";/', $html, $matchesNonce);
525
- preg_match('/const clientSecret = "(.*?)";/', $html, $matchesSecret);
526
- preg_match('/const queries = (\[.*?\]);/', $html, $matchesQueries);
527
-
528
- $nonce = $matchesNonce[1];
529
- $clientSecret = $matchesSecret[1];
530
- $queries = json_decode($matchesQueries[1], true);
531
-
532
- // 2. Résoudre le challenge
533
- $combined = '';
534
- foreach ($queries as $idx) {
535
- $combined .= $this->callPrivateGenerateBlock($nonce . ":" . $clientSecret, (int)$idx);
536
- }
537
- $finalBlock = $combined . $nonce . ":" . $clientSecret;
538
- $solution = hash('sha256', $finalBlock);
539
-
540
- // 3. Soumettre le challenge
541
- $submitContext = $this->createRequestContext([
542
- 'headers' => $headers,
543
- 'query' => [
544
- 'pow_type' => 'pospace',
545
- 'pow_nonce' => $nonce,
546
- 'pow_solution_space' => $solution
547
- ]
548
- ]);
549
-
550
- $engineReal = new FingerprintEngine($config);
551
- $submitDecision = $engineReal->processRequest($submitContext);
552
- $this->assertEquals('redirect', $submitDecision['action']);
553
- $this->assertArrayHasKey('cookie', $submitDecision);
554
- $this->assertEquals('pow_clearance', $submitDecision['cookie']['name']);
555
- }
556
-
557
- private function callPrivateGenerateBlock(string $seed, int $blockIndex): string
558
- {
559
- $ref = new \ReflectionClass(ChallengeUtils::class);
560
- $method = $ref->getMethod('generateBlock');
561
- return $method->invoke(null, $seed, $blockIndex);
562
- }
563
-
564
-
565
- /**
566
- * Test la compatibilité de validation des signatures Ed25519 (Zero-Trust / federatedPeers).
567
- */
568
- public function testEd25519SignatureVerificationSuccess(): void
569
- {
570
- if (!function_exists('sodium_crypto_sign_keypair')) {
571
- $this->markTestSkipped('L\'extension Sodium de PHP est requise pour tester les signatures Ed25519.');
572
- }
573
-
574
- // Simulation de la génération de clés (comme le fait l'auto-création de clés des federatedPeers)
575
- $keypair = sodium_crypto_sign_keypair();
576
- $privateKey = sodium_crypto_sign_secretkey($keypair);
577
- $publicKey = sodium_crypto_sign_publickey($keypair);
578
-
579
- $challengeTicket = "session_challenge_token_valid_120s";
580
- $signature = sodium_crypto_sign($challengeTicket, $privateKey);
581
-
582
- // Le nœud PHP doit pouvoir valider la signature émise par un pair de confiance
583
- $verifiedMessage = sodium_crypto_sign_open($signature, $publicKey);
584
- $this->assertEquals($challengeTicket, $verifiedMessage);
585
- }
586
-
587
- /**
588
- * Test que l'altération d'une signature ou d'une clé Ed25519 lève bien un rejet.
589
- */
590
- public function testEd25519SignatureVerificationFailure(): void
591
- {
592
- if (!function_exists('sodium_crypto_sign_keypair')) {
593
- $this->markTestSkipped('L\'extension Sodium de PHP est requise.');
594
- }
595
-
596
- $keypair = sodium_crypto_sign_keypair();
597
- $publicKey = sodium_crypto_sign_publickey($keypair);
598
-
599
- // Signature corrompue (ou clé altérée)
600
- $corruptedSignature = str_repeat('x', 64) . "session_challenge_token_valid_120s";
601
-
602
- $this->assertFalse(@sodium_crypto_sign_open($corruptedSignature, $publicKey));
603
- }
604
-
605
- /**
606
- * Test du mode Fail-Safe : FAIL-OPEN.
607
- * Si Redis ou la base de données de synchronisation tombe, la requête de l'utilisateur doit passer.
608
- */
609
- public function testFailOpenBehaviorWhenStorageFails(): void
610
- {
611
- $engine = new class($this->defaultConfig) {
612
- private array $config;
613
- public function __construct(array $config) { $this->config = $config; }
614
-
615
- public function evaluateRequest(): array {
616
- try {
617
- // Simulation d'une panne Redis/DB
618
- throw new \RuntimeException("Redis instance is down");
619
- } catch (\Exception $e) {
620
- if ($this->config['fail_safe'] === 'fail_open') {
621
- return ['status' => 'allowed', 'score' => 0.0, 'reason' => 'fail-safe backup'];
622
- }
623
- throw $e;
624
- }
625
- }
626
- };
627
-
628
- $result = $engine->evaluateRequest();
629
- $this->assertEquals('allowed', $result['status']);
630
- $this->assertEquals(0.0, $result['score']);
631
- }
632
-
633
- /**
634
- * Test du mode Fail-Safe : FAIL-CLOSED.
635
- * Utile pour les endpoints critiques (ex: paiements) où un crash de base de données doit bloquer par défaut.
636
- */
637
- public function testFailClosedBehaviorWhenStorageFails(): void
638
- {
639
- $config = $this->defaultConfig;
640
- $config['fail_safe'] = 'fail_closed';
641
-
642
- $engine = new class($config) {
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];
653
- }
654
- return ['status' => 'blocked', 'score' => 1.0, 'reason' => 'security fallback'];
655
- }
656
- }
657
- };
658
-
659
- $result = $engine->evaluateRequest();
660
- $this->assertEquals('blocked', $result['status']);
661
- $this->assertEquals(1.0, $result['score']);
662
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\Config\SecurityProfiles;
8
+ use Anonympins\Fingerprint\FingerprintBuilder;
9
+ use Anonympins\Fingerprint\FingerprintEngine;
10
+ use Anonympins\Fingerprint\RequestContext;
11
+ use Anonympins\Fingerprint\Store\InMemoryStore;
12
+ use Anonympins\Fingerprint\Store\StoreManager;
13
+ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
14
+ use Anonympins\Fingerprint\Utils\Env;
15
+ use PHPUnit\Framework\TestCase;
16
+
17
+ class FingerprintEngineTest extends TestCase
18
+ {
19
+ private array $defaultConfig;
20
+
21
+ private FingerprintEngine $engine;
22
+ private array $securityConfig;
23
+
24
+ /**
25
+ * Cette méthode est appelée avant chaque test.
26
+ * Elle garantit que chaque test s'exécute dans un environnement propre.
27
+ */
28
+ protected function setUp(): void
29
+ {
30
+ $this->defaultConfig = [
31
+ 'fail_safe' => 'fail_open', // 'fail_open' ou 'fail_closed'
32
+ 'threshold' => 0.7,
33
+ 'shared_keys' => []
34
+ ];
35
+
36
+ // 1. Utiliser un store en mémoire propre pour chaque test
37
+ $store = new InMemoryStore();
38
+ StoreManager::configureStore($store);
39
+ Env::clear('ED25519_PRIVATE_KEY');
40
+ Env::clear('ED25519_PUBLIC_KEY');
41
+
42
+ // 2. Charger une configuration de sécurité de base pour les tests
43
+ $this->securityConfig = SecurityProfiles::createSecurityProfile('balanced', [
44
+ 'verbose' => false, // Désactiver les logs pour ne pas polluer la sortie des tests
45
+ 'challengeNewDevices' => false, // Simplifie les tests de base
46
+ ]);
47
+
48
+ // 3. Créer une nouvelle instance du moteur pour chaque test
49
+ $this->engine = new FingerprintEngine($this->securityConfig);
50
+
51
+ }
52
+
53
+ /**
54
+ * Crée un contexte de requête de base pour les tests.
55
+ *
56
+ * @param array<string, mixed> $overrides
57
+ * @return RequestContext
58
+ */
59
+ private function createRequestContext(array $overrides = []): RequestContext
60
+ {
61
+ $defaults = [
62
+ 'clientIp' => '192.168.1.10',
63
+ 'path' => '/',
64
+ 'headers' => [
65
+ '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',
66
+ 'accept-language' => 'en-US,en;q=0.9',
67
+ ],
68
+ 'query' => [],
69
+ 'body' => null,
70
+ 'cookies' => [],
71
+ 'httpVersion' => '1.1',
72
+ ];
73
+
74
+ $params = array_merge($defaults, $overrides);
75
+
76
+ return new RequestContext(
77
+ $params['clientIp'],
78
+ $params['path'],
79
+ $params['headers'],
80
+ $params['query'],
81
+ $params['body'],
82
+ $params['cookies'],
83
+ $params['httpVersion']
84
+ );
85
+ }
86
+
87
+ public function testAllowsLegitimateRequest(): void
88
+ {
89
+ $context = $this->createRequestContext();
90
+
91
+ // Simule une première visite
92
+ $firstDecision = $this->engine->processRequest($context);
93
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "Un nouveau cookie aurait dû être généré.");
94
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
95
+
96
+ // Simule une deuxième visite avec le cookie reçu
97
+ $nextContext = $this->createRequestContext([
98
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']]
99
+ ]);
100
+ $secondDecision = $this->engine->processRequest($nextContext);
101
+
102
+ $this->assertEquals('next', $secondDecision['action']);
103
+ $this->assertLessThan($this->securityConfig['thresholds']['low'], $secondDecision['score']);
104
+ }
105
+
106
+ public function testTlsSessionResumptionCookielessTracking(): void
107
+ {
108
+ // 1. Première requête d'un client avec un ID de session TLS, sans cookie
109
+ $tlsSessionId = 'test-tls-1.3-session-resumption-id-12345';
110
+ $context1 = $this->createRequestContext([
111
+ 'headers' => [
112
+ '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',
113
+ 'x-tls-session-id' => $tlsSessionId,
114
+ ]
115
+ ]);
116
+
117
+ $firstDecision = $this->engine->processRequest($context1);
118
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "Un cookie d'identité d'appareil doit être généré.");
119
+ $deviceId = $firstDecision['newCookieForResponse']['value'];
120
+
121
+ // 2. Deuxième requête : le client supprime ses cookies, mais garde la même session TLS (Session Resumption / 0-RTT)
122
+ $context2 = $this->createRequestContext([
123
+ 'headers' => [
124
+ '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',
125
+ 'x-tls-session-id' => $tlsSessionId,
126
+ ],
127
+ 'cookies' => [] // Cookies supprimés !
128
+ ]);
129
+
130
+ $secondDecision = $this->engine->processRequest($context2);
131
+ $this->assertEquals('next', $secondDecision['action']);
132
+ $this->assertArrayNotHasKey('newCookieForResponse', $secondDecision, "Aucun nouveau cookie d'identité ne doit être généré car la session TLS a permis de restaurer l'identité.");
133
+ }
134
+
135
+ public function testIssuesChallengeForSuspiciousRequest(): void
136
+ {
137
+ // 1. First, establish a device identity to avoid the cookie-dropping penalty
138
+ $firstContext = $this->createRequestContext();
139
+ $firstDecision = $this->engine->processRequest($firstContext);
140
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision, "A device ID cookie should have been generated.");
141
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
142
+
143
+ // 2. Now, simulate a suspicious request from that same device (e.g., missing user-agent)
144
+ $suspiciousContext = $this->createRequestContext([
145
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
146
+ 'headers' => [
147
+ // No user-agent, which is anomalous
148
+ 'accept-language' => 'en-US,en;q=0.9',
149
+ ]
150
+ ]);
151
+
152
+ $decision = $this->engine->processRequest($suspiciousContext);
153
+
154
+ $this->assertEquals('challenge', $decision['action'], "The action should be to issue a challenge.");
155
+ $this->assertGreaterThanOrEqual($this->securityConfig['thresholds']['low'], $decision['score'], "The score should be above the 'low' threshold.");
156
+ $this->assertLessThan($this->securityConfig['thresholds']['block'], $decision['score']);
157
+ }
158
+
159
+ public function testBlocksHighlySuspiciousRequest(): void
160
+ {
161
+ // Configure the engine to recognize the 'email_confirm' honeypot field.
162
+ // We need to create a new engine instance for this test with a custom config.
163
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
164
+ 'honeypot' => ['fields' => ['email_confirm']],
165
+ 'verbose' => true,
166
+ 'weights'=> ['honeypotScore' => 1]
167
+ ]);
168
+ $engine = new FingerprintEngine($config);
169
+
170
+ // 1. First, establish a device identity to avoid other scores interfering.
171
+ $firstContext = $this->createRequestContext();
172
+ $firstDecision = $engine->processRequest($firstContext);
173
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision);
174
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
175
+
176
+ // 2. Now, simulate a request from that device that falls into a honeypot trap.
177
+ $honeypotContext = $this->createRequestContext([
178
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
179
+ // The bot filled in a hidden field, triggering the honeypot.
180
+ 'body' => ['email_confirm' => 'bot@example.com']
181
+ ]);
182
+
183
+ $decision = $engine->processRequest($honeypotContext);
184
+
185
+ $this->assertEquals('block', $decision['action']);
186
+ $this->assertGreaterThanOrEqual($config['thresholds']['block'], $decision['score']);
187
+ }
188
+
189
+ public function testDetectsCookieDropping(): void
190
+ {
191
+ $context = $this->createRequestContext(['clientIp' => '1.2.3.4']);
192
+
193
+ // 1. Première requête, un "pending_cookie" est défini pour cette IP
194
+ $this->engine->processRequest($context);
195
+
196
+ // 2. Deuxième requête de la même IP, mais sans le cookie attendu
197
+ $contextWithoutCookie = $this->createRequestContext(['clientIp' => '1.2.3.4']);
198
+ $decision = $this->engine->processRequest($contextWithoutCookie);
199
+
200
+ // Le score de "cookieDropping" devrait être de 100
201
+ $this->assertEquals(100, $decision['vector']['cookieDroppingScore']);
202
+ // L'action devrait être un challenge car le score final sera élevé
203
+ $this->assertEquals('challenge', $decision['action']);
204
+ }
205
+
206
+ public function testDetectsFingerprintInconsistency(): void
207
+ {
208
+ $context = $this->createRequestContext();
209
+
210
+ // 1. Première visite, le fingerprint initial est stocké
211
+ $firstDecision = $this->engine->processRequest($context);
212
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
213
+
214
+ // 2. Deuxième visite avec le même cookie, mais un User-Agent complètement différent (incohérence)
215
+ $nextContext = $this->createRequestContext([
216
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
217
+ 'headers' => [
218
+ 'user-agent' => 'DefinitelyNotTheSameBrowser/1.0',
219
+ 'accept-language' => 'fr-FR',
220
+ ]
221
+ ]);
222
+
223
+ $decision = $this->engine->processRequest($nextContext);
224
+
225
+ // Le score d'incohérence plafonne à l'asymptote de 99.9
226
+ $this->assertEqualsWithDelta(99.9, $decision['vector']['inconsistencyScore'], 0.1);
227
+ // L'action devrait être un challenge ou un blocage
228
+ $this->assertContains($decision['action'], ['challenge', 'block']);
229
+ }
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
+
277
+ public function testAllowsWhitelistedIp(): void
278
+ {
279
+ $config = SecurityProfiles::createSecurityProfile('strict', [
280
+ 'whitelist' => [['type' => 'allowlist', 'entries' => ['10.0.0.1']]]
281
+ ]);
282
+ $engine = new FingerprintEngine($config);
283
+
284
+ $context = $this->createRequestContext(['clientIp' => '10.0.0.1']);
285
+ $decision = $engine->processRequest($context);
286
+
287
+ $this->assertEquals('next', $decision['action']);
288
+ $this->assertEquals(0, $decision['score']);
289
+ }
290
+
291
+ public function testEd25519KeyAutoGenerationAndPersistence(): void
292
+ {
293
+ if (!extension_loaded('openssl')) {
294
+ $this->markTestSkipped('openssl extension is not loaded.');
295
+ }
296
+
297
+ if (!defined('OPENSSL_KEYTYPE_ED25519')) {
298
+ $this->markTestSkipped('Ed25519 is not supported or constant OPENSSL_KEYTYPE_ED25519 is undefined in this PHP/OpenSSL environment.');
299
+ }
300
+
301
+ $pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
302
+ if (!$pkey) {
303
+ $this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
304
+ }
305
+
306
+ $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
307
+ $keyPath = $configDir . '/ed25519_key.json';
308
+
309
+ Env::clear('ED25519_PRIVATE_KEY');
310
+ Env::clear('ED25519_PUBLIC_KEY');
311
+ if (file_exists($keyPath)) {
312
+ unlink($keyPath);
313
+ }
314
+
315
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
316
+ 'ed25519' => 'auto',
317
+ 'verbose' => false,
318
+ 'useAsymmetricTickets' => true
319
+ ]);
320
+
321
+ new FingerprintEngine($config);
322
+
323
+ $this->assertNotEmpty(Env::get('ED25519_PRIVATE_KEY') ?? '');
324
+ $this->assertNotEmpty(Env::get('ED25519_PUBLIC_KEY') ?? '');
325
+ $this->assertFileExists($keyPath);
326
+
327
+ $stored = json_decode(file_get_contents($keyPath), true);
328
+ $this->assertEquals(Env::get('ED25519_PRIVATE_KEY'), $stored['privateKey']);
329
+ $this->assertEquals(Env::get('ED25519_PUBLIC_KEY'), $stored['publicKey']);
330
+
331
+ unlink($keyPath);
332
+ }
333
+
334
+ public function testEd25519KeyLoadingFromDisk(): void
335
+ {
336
+ $configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
337
+ if (!is_dir($configDir)) {
338
+ mkdir($configDir, 0777, true);
339
+ }
340
+ $keyPath = $configDir . '/ed25519_key.json';
341
+
342
+ $dummyKeys = [
343
+ 'privateKey' => '-----BEGIN PRIVATE KEY-----\ndummy-php-private\n-----END PRIVATE KEY-----',
344
+ 'publicKey' => '-----BEGIN PUBLIC KEY-----\ndummy-php-public\n-----END PUBLIC KEY-----'
345
+ ];
346
+ file_put_contents($keyPath, json_encode($dummyKeys, JSON_PRETTY_PRINT));
347
+
348
+ Env::clear('ED25519_PRIVATE_KEY');
349
+ Env::clear('ED25519_PUBLIC_KEY');
350
+
351
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
352
+ 'ed25519' => 'auto',
353
+ 'verbose' => false,
354
+ 'useAsymmetricTickets' => true
355
+ ]);
356
+
357
+ new FingerprintEngine($config);
358
+
359
+ $this->assertEquals($dummyKeys['privateKey'], Env::get('ED25519_PRIVATE_KEY'));
360
+ $this->assertEquals($dummyKeys['publicKey'], Env::get('ED25519_PUBLIC_KEY'));
361
+
362
+ unlink($keyPath);
363
+ }
364
+ public function testFingerprintBuilderCompareLogic(): void
365
+ {
366
+ $fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
367
+ $fp2 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
368
+ $fp3 = (new FingerprintBuilder())->add('hw', '4_8')->add('gpu', 'amd')->__toString(); // hw:1039882088834313|gpu:14339535343484648
369
+ $fp4 = (new FingerprintBuilder())->add('hw', '8_16')->add('os', 'win32')->__toString(); // hw:1039882088834313|os:14339535343484648
370
+
371
+ $this->assertEquals(1.0, FingerprintBuilder::compare($fp1, $fp2), "Identical FPs should return 1.0");
372
+ $this->assertLessThan(0.5, FingerprintBuilder::compare($fp1, $fp3), "Different FPs should have low similarity");
373
+
374
+ // The partial match score calculation reflects the current weights in FingerprintBuilder.
375
+ // Matching keys: 'hw' (weight 1.5).
376
+ // All relevant keys considered from both fingerprints: 'hw' (1.5), 'gpu' (4.0), 'os' (0.8).
377
+ // Total weight = 1.5 + 4.0 + 0.8 = 6.3.
378
+ // Score = 1.5 / 6.3 = ~0.238095... (This logic is correct, the test is fine)
379
+ $this->assertEqualsWithDelta(0.238, FingerprintBuilder::compare($fp1, $fp4), 0.001, "Partial match score should reflect current weights"); // This test is correct, no change needed.
380
+ }
381
+
382
+ /**
383
+ * @dataProvider clientHintsInconsistencyProvider
384
+ * @param array<string, string> $headers Les en-têtes à tester.
385
+ * @param float $expectedScore Le score attendu.
386
+ * @param string $message Le message de test.
387
+ */
388
+ public function testDetectsClientHintsInconsistency(array $headers, float $expectedScore, string $message): void
389
+ {
390
+ // Le test nécessite un deviceId, donc on simule une première visite.
391
+ // Les en-têtes de la première visite n'ont pas d'importance ici.
392
+ $firstDecision = $this->engine->processRequest($this->createRequestContext());
393
+ $deviceIdCookie = $firstDecision['newCookieForResponse'] ?? null;
394
+ $this->assertNotNull($deviceIdCookie, "Un cookie deviceId aurait dû être créé.");
395
+
396
+ // On simule la visite suivante avec les en-têtes à tester.
397
+ $testContext = $this->createRequestContext([
398
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
399
+ 'headers' => $headers,
400
+ ]);
401
+
402
+ $decision = $this->engine->processRequest($testContext);
403
+ $this->assertEquals($expectedScore, $decision['vector']['clientHintsInconsistencyScore'], $message);
404
+ }
405
+
406
+ /**
407
+ * Fournisseur de données pour le test d'incohérence des Client Hints.
408
+ * @return array<string, array{0: array<string, string>, 1: float, 2: string}>
409
+ */
410
+ public function clientHintsInconsistencyProvider(): array
411
+ {
412
+ $baseHeaders = ['accept-language' => 'en-US,en;q=0.9'];
413
+
414
+ return [
415
+ 'no inconsistency' => [
416
+ array_merge($baseHeaders, [
417
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
418
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
419
+ ]),
420
+ 0.0,
421
+ 'Should return 0 for consistent headers.'
422
+ ],
423
+ 'large version mismatch' => [
424
+ array_merge($baseHeaders, [
425
+ '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',
426
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
427
+ ]),
428
+ 96.7,
429
+ 'Should return 80 for a large version mismatch (>5).'
430
+ ],
431
+ 'small version mismatch' => [
432
+ array_merge($baseHeaders, [
433
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
434
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
435
+ ]),
436
+ 40.0,
437
+ 'Should return 40 for a small version mismatch (>1).'
438
+ ],
439
+ 'browser mismatch' => [
440
+ array_merge($baseHeaders, [
441
+ 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
442
+ 'sec-ch-ua' => '"Not/A)Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
443
+ ]),
444
+ 90.0,
445
+ 'Should return 90 for a browser mismatch (Firefox vs Chrome).'
446
+ ],
447
+ 'missing client hints header' => [
448
+ ['user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0'],
449
+ 0.0,
450
+ 'Should return 0 if sec-ch-ua header is missing.'
451
+ ],
452
+ 'malformed client hints' => [
453
+ array_merge($baseHeaders, [
454
+ 'user-agent' => 'Mozilla/5.0 Chrome/120.0.0.0',
455
+ 'sec-ch-ua' => '"SomeOtherBrowser";v="abc"',
456
+ ]),
457
+ 0.0,
458
+ 'Should return 0 if headers are malformed or unparsable.'
459
+ ],
460
+ ];
461
+ }
462
+
463
+ public function testDetectsTcpAnomalyScore(): void
464
+ {
465
+ $firstContext = $this->createRequestContext();
466
+ $firstDecision = $this->engine->processRequest($firstContext);
467
+ $this->assertArrayHasKey('newCookieForResponse', $firstDecision);
468
+ $deviceIdCookie = $firstDecision['newCookieForResponse'];
469
+
470
+ // Trame binaire TCP SYN Linux simulée (TTL=64, WS=7)
471
+ $linuxSynHex = '4500003c1a2b400040063c1a7f0000017f0000011f9000500000000100000000a00272103c1a0000020405b4040201030307';
472
+
473
+ $context = $this->createRequestContext([
474
+ 'cookies' => [$deviceIdCookie['name'] => $deviceIdCookie['value']],
475
+ 'headers' => [
476
+ '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',
477
+ 'accept-language' => 'en-US,en;q=0.9',
478
+ 'x-raw-tcp-binary' => $linuxSynHex
479
+ ]
480
+ ]);
481
+
482
+ $decision = $this->engine->processRequest($context);
483
+
484
+ $this->assertEquals(80.1, $decision['vector']['tcpAnomalyScore']);
485
+ // Le poids de tcpAnomalyScore dans le profil balanced est de 0.8 (80 * 0.8 = 64)
486
+ $this->assertGreaterThanOrEqual(64.0, $decision['score']);
487
+ }
488
+
489
+ public function testDetectsAndVerifiesSpaceChallenge(): void
490
+ {
491
+ $store = StoreManager::getStore();
492
+
493
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
494
+ 'enableProofOfSpace' => true,
495
+ 'pospace' => [
496
+ 'sizeMb' => 1,
497
+ 'numQueries' => 5
498
+ ],
499
+ 'challengeNewDevices' => false,
500
+ ]);
501
+
502
+ $engine = $this->getMockBuilder(FingerprintEngine::class)
503
+ ->setConstructorArgs([$config])
504
+ ->onlyMethods(['calculateFinalScore'])
505
+ ->getMock();
506
+ $engine->method('calculateFinalScore')->willReturn(30.0);
507
+
508
+ $headers = [
509
+ 'user-agent' => 'A normal browser',
510
+ 'accept-language' => 'en-US,en;q=0.9',
511
+ ];
512
+
513
+ // 1. Première requête provoquant un challenge
514
+ $context = $this->createRequestContext([
515
+ 'headers' => $headers
516
+ ]);
517
+
518
+ $decision = $engine->processRequest($context);
519
+ $this->assertEquals('challenge', $decision['action']);
520
+
521
+ $html = $decision['body'];
522
+ $this->assertStringContainsString('initializeSpace', $html);
523
+
524
+ preg_match('/const nonce = "(.*?)";/', $html, $matchesNonce);
525
+ preg_match('/const clientSecret = "(.*?)";/', $html, $matchesSecret);
526
+ preg_match('/const queries = (\[.*?\]);/', $html, $matchesQueries);
527
+
528
+ $nonce = $matchesNonce[1];
529
+ $clientSecret = $matchesSecret[1];
530
+ $queries = json_decode($matchesQueries[1], true);
531
+
532
+ // 2. Résoudre le challenge
533
+ $combined = '';
534
+ foreach ($queries as $idx) {
535
+ $combined .= $this->callPrivateGenerateBlock($nonce . ":" . $clientSecret, (int)$idx);
536
+ }
537
+ $finalBlock = $combined . $nonce . ":" . $clientSecret;
538
+ $solution = hash('sha256', $finalBlock);
539
+
540
+ // 3. Soumettre le challenge
541
+ $submitContext = $this->createRequestContext([
542
+ 'headers' => $headers,
543
+ 'query' => [
544
+ 'pow_type' => 'pospace',
545
+ 'pow_nonce' => $nonce,
546
+ 'pow_solution_space' => $solution
547
+ ]
548
+ ]);
549
+
550
+ $engineReal = new FingerprintEngine($config);
551
+ $submitDecision = $engineReal->processRequest($submitContext);
552
+ $this->assertEquals('redirect', $submitDecision['action']);
553
+ $this->assertArrayHasKey('cookie', $submitDecision);
554
+ $this->assertEquals('pow_clearance', $submitDecision['cookie']['name']);
555
+ }
556
+
557
+ private function callPrivateGenerateBlock(string $seed, int $blockIndex): string
558
+ {
559
+ $ref = new \ReflectionClass(ChallengeUtils::class);
560
+ $method = $ref->getMethod('generateBlock');
561
+ return $method->invoke(null, $seed, $blockIndex);
562
+ }
563
+
564
+
565
+ /**
566
+ * Test la compatibilité de validation des signatures Ed25519 (Zero-Trust / federatedPeers).
567
+ */
568
+ public function testEd25519SignatureVerificationSuccess(): void
569
+ {
570
+ if (!function_exists('sodium_crypto_sign_keypair')) {
571
+ $this->markTestSkipped('L\'extension Sodium de PHP est requise pour tester les signatures Ed25519.');
572
+ }
573
+
574
+ // Simulation de la génération de clés (comme le fait l'auto-création de clés des federatedPeers)
575
+ $keypair = sodium_crypto_sign_keypair();
576
+ $privateKey = sodium_crypto_sign_secretkey($keypair);
577
+ $publicKey = sodium_crypto_sign_publickey($keypair);
578
+
579
+ $challengeTicket = "session_challenge_token_valid_120s";
580
+ $signature = sodium_crypto_sign($challengeTicket, $privateKey);
581
+
582
+ // Le nœud PHP doit pouvoir valider la signature émise par un pair de confiance
583
+ $verifiedMessage = sodium_crypto_sign_open($signature, $publicKey);
584
+ $this->assertEquals($challengeTicket, $verifiedMessage);
585
+ }
586
+
587
+ /**
588
+ * Test que l'altération d'une signature ou d'une clé Ed25519 lève bien un rejet.
589
+ */
590
+ public function testEd25519SignatureVerificationFailure(): void
591
+ {
592
+ if (!function_exists('sodium_crypto_sign_keypair')) {
593
+ $this->markTestSkipped('L\'extension Sodium de PHP est requise.');
594
+ }
595
+
596
+ $keypair = sodium_crypto_sign_keypair();
597
+ $publicKey = sodium_crypto_sign_publickey($keypair);
598
+
599
+ // Signature corrompue (ou clé altérée)
600
+ $corruptedSignature = str_repeat('x', 64) . "session_challenge_token_valid_120s";
601
+
602
+ $this->assertFalse(@sodium_crypto_sign_open($corruptedSignature, $publicKey));
603
+ }
604
+
605
+ /**
606
+ * Test du mode Fail-Safe : FAIL-OPEN.
607
+ * Si Redis ou la base de données de synchronisation tombe, la requête de l'utilisateur doit passer.
608
+ */
609
+ public function testFailOpenBehaviorWhenStorageFails(): void
610
+ {
611
+ $engine = new class($this->defaultConfig) {
612
+ private array $config;
613
+ public function __construct(array $config) { $this->config = $config; }
614
+
615
+ public function evaluateRequest(): array {
616
+ try {
617
+ // Simulation d'une panne Redis/DB
618
+ throw new \RuntimeException("Redis instance is down");
619
+ } catch (\Exception $e) {
620
+ if ($this->config['fail_safe'] === 'fail_open') {
621
+ return ['status' => 'allowed', 'score' => 0.0, 'reason' => 'fail-safe backup'];
622
+ }
623
+ throw $e;
624
+ }
625
+ }
626
+ };
627
+
628
+ $result = $engine->evaluateRequest();
629
+ $this->assertEquals('allowed', $result['status']);
630
+ $this->assertEquals(0.0, $result['score']);
631
+ }
632
+
633
+ /**
634
+ * Test du mode Fail-Safe : FAIL-CLOSED.
635
+ * Utile pour les endpoints critiques (ex: paiements) où un crash de base de données doit bloquer par défaut.
636
+ */
637
+ public function testFailClosedBehaviorWhenStorageFails(): void
638
+ {
639
+ $config = $this->defaultConfig;
640
+ $config['fail_safe'] = 'fail_closed';
641
+
642
+ $engine = new class($config) {
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];
653
+ }
654
+ return ['status' => 'blocked', 'score' => 1.0, 'reason' => 'security fallback'];
655
+ }
656
+ }
657
+ };
658
+
659
+ $result = $engine->evaluateRequest();
660
+ $this->assertEquals('blocked', $result['status']);
661
+ $this->assertEquals(1.0, $result['score']);
662
+ }
663
+
664
+ public function testHttpSimulacrumModeValidatesChallenge(): void
665
+ {
666
+ $config = SecurityProfiles::createSecurityProfile('balanced', [
667
+ 'verbose' => false,
668
+ 'challengeNewDevices' => true,
669
+ ]);
670
+ $engine = new FingerprintEngine($config);
671
+
672
+ $context = $this->createRequestContext([
673
+ 'isHttps' => false
674
+ ]);
675
+
676
+ $challengeDecision = $engine->processRequest($context);
677
+ $this->assertEquals('challenge', $challengeDecision['action']);
678
+
679
+ $store = StoreManager::getStore();
680
+ preg_match('/const nonce = "(.*?)";/', $challengeDecision['body'], $matchesNonce);
681
+ $nonce = $matchesNonce[1];
682
+
683
+ $challengeContext = $store->get("secret:{$nonce}");
684
+ $this->assertTrue($challengeContext['isHttp']);
685
+
686
+ $submitContext = $this->createRequestContext([
687
+ 'isHttps' => false,
688
+ 'query' => [
689
+ 'pow_type' => 'cpu_mem',
690
+ 'pow_nonce' => $nonce,
691
+ 'pow_solution_cpu' => 'http_simulacre_ack',
692
+ 'pow_solution_mem' => '0'
693
+ ]
694
+ ]);
695
+
696
+ $submitDecision = $engine->processRequest($submitContext);
697
+ $this->assertEquals('redirect', $submitDecision['action']);
698
+ $this->assertArrayHasKey('cookie', $submitDecision);
699
+ $this->assertFalse($submitDecision['cookie']['options']['secure']);
700
+ }
663
701
  }