@anonympins/fingerprint 0.4.4 → 0.4.6

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,132 +1,142 @@
1
- import {beforeEach, describe, expect, it} from 'vitest';
2
- import {__internal, configureStore, FingerprintEngine} from '../fingerprint.js';
3
-
4
- describe('IP Reputation Local System (Node.js)', () => {
5
- let mockStore;
6
-
7
- beforeEach(() => {
8
- mockStore = {
9
- _map: new Map(),
10
- async get(key) { return this._map.get(key); },
11
- async set(key, value) { this._map.set(key, value); },
12
- async has(key) { return this._map.has(key); },
13
- async delete(key) { this._map.delete(key); }
14
- };
15
- configureStore(mockStore);
16
- });
17
-
18
- it('should return a default score of 0 for unknown IPs', async () => {
19
- const score = await __internal.getIpReputationScore('1.1.1.1');
20
- expect(score).toBe(0);
21
- });
22
-
23
- it('should correctly increment and decrement score values', async () => {
24
- const ip = '192.168.1.1';
25
- await __internal.updateIpReputationScore(ip, 30);
26
- let score = await __internal.getIpReputationScore(ip);
27
- expect(score).toBe(30);
28
-
29
- await __internal.updateIpReputationScore(ip, -10);
30
- score = await __internal.getIpReputationScore(ip);
31
- expect(score).toBe(20);
32
- });
33
-
34
- it('should clamp the reputation score within bounds [0, 100]', async () => {
35
- const ip = '10.0.0.1';
36
- await __internal.updateIpReputationScore(ip, 150);
37
- let score = await __internal.getIpReputationScore(ip);
38
- expect(score).toBe(100);
39
-
40
- await __internal.updateIpReputationScore(ip, -200);
41
- score = await __internal.getIpReputationScore(ip);
42
- expect(score).toBe(0);
43
- });
44
-
45
- it('should apply passive decay of 2 points per hour of inactivity', async () => {
46
- const ip = '172.16.0.1';
47
- const now = Date.now();
48
-
49
- // Simulation d'un score de 50 mis à jour il y a 3 heures (3h * 2 points/heure = 6 points de perte)
50
- await mockStore.set(`ip-reputation:${ip}`, {
51
- score: 50,
52
- lastUpdate: now - (3 * 60 * 60 * 1000)
53
- });
54
-
55
- const score = await __internal.getIpReputationScore(ip);
56
- expect(score).toBe(44);
57
- });
58
-
59
- it('should integrate ipReputationScore into the final score calculation', async () => {
60
- const ip = '1.2.3.4';
61
- await __internal.updateIpReputationScore(ip, 60); // Set IP reputation to 60
62
-
63
- const mockSecurityConfig = {
64
- weights: {
65
- ipReputationScore: 0.5, // Give it a weight
66
- historyScore: 0.0, // Other weights to 0 for isolation
67
- rotationScore: 0.0,
68
- headerAnomalyScore: 0.0,
69
- requestPatternScore: 0.0,
70
- inconsistencyScore: 0.0,
71
- honeypotScore: 0.0,
72
- behaviorScore: 0.0,
73
- botScore: 0.0,
74
- crossLayerInconsistencyScore: 0.0,
75
- tlsSpoofingScore: 0.0,
76
- timeInconsistencyScore: 0.0,
77
- clickVarianceScore: 0.0,
78
- clientHintsInconsistencyScore: 0.0,
79
- subnetScore: 0.0,
80
- },
81
- thresholds: { low: 0, medium: 0, high: 0, block: 100 }, // Irrelevant for this test
82
- };
83
-
84
- const ipRepScore = await __internal.getIpReputationScore(ip);
85
- const suspicionVector = { ipReputationScore: ipRepScore };
86
-
87
- const engine = new FingerprintEngine(mockSecurityConfig);
88
- const finalScore = engine.calculateFinalScore(suspicionVector);
89
-
90
- // Expected score: 60 (ipRepScore) * 0.5 (weight) = 30
91
- expect(finalScore).toBe(30.0);
92
- });
93
-
94
- it('should return correct subnet for IPv4 and IPv6', async () => {
95
- const ipv4Subnet = __internal.getIpSubnet('192.168.1.50', 24, 48);
96
- expect(ipv4Subnet).toBe('192.168.1.0/24');
97
-
98
- const ipv6Subnet = __internal.getIpSubnet('2001:db8:abcd:12::1', 24, 48);
99
- expect(ipv6Subnet).toBe('2001:db8:abcd:0:0:0:0:0/48');
100
- });
101
-
102
- it('should calculate client hints inconsistency score correctly', async () => {
103
- const mockContextMismatch = {
104
- headers: {
105
- 'user-agent': 'Mozilla/5.0 Firefox/117.0',
106
- 'sec-ch-ua': '"Google Chrome";v="117"'
107
- }
108
- };
109
- const scoreMismatch = __internal.getClientHintsInconsistencyScore(mockContextMismatch);
110
- expect(scoreMismatch.clientHintsInconsistencyScore).toBe(90);
111
-
112
- const mockContextVersionDrift = {
113
- headers: {
114
- 'user-agent': 'Mozilla/5.0 Chrome/110.0',
115
- 'sec-ch-ua': '"Google Chrome";v="117"'
116
- }
117
- };
118
- const scoreVersionDrift = __internal.getClientHintsInconsistencyScore(mockContextVersionDrift);
119
- expect(scoreVersionDrift.clientHintsInconsistencyScore).toBe(80);
120
- });
121
-
122
- it('should integrate subnet history into subnet score', async () => {
123
- const mockContext = { clientIp: '192.168.1.50' };
124
- let score = await __internal.getSubnetScore(mockContext);
125
- expect(score.subnetScore).toBe(0);
126
- for (let i = 1; i <= 12; i++) {
127
- await __internal.updateSubnetMetrics(mockContext, `dev-${i}`, 40);
128
- }
129
- score = await __internal.getSubnetScore(mockContext);
130
- expect(score.subnetScore).toBeGreaterThan(0);
131
- });
1
+ import {beforeEach, describe, expect, it} from 'vitest';
2
+ import {__internal, configureStore, FingerprintEngine} from '../fingerprint.js';
3
+
4
+ describe('IP Reputation Local System (Node.js)', () => {
5
+ let mockStore;
6
+
7
+ beforeEach(() => {
8
+ mockStore = {
9
+ _map: new Map(),
10
+ async get(key) { return this._map.get(key); },
11
+ async set(key, value) { this._map.set(key, value); },
12
+ async has(key) { return this._map.has(key); },
13
+ async delete(key) { this._map.delete(key); }
14
+ };
15
+ configureStore(mockStore);
16
+ });
17
+
18
+ it('should return a default score of 0 for unknown IPs', async () => {
19
+ const score = await __internal.getIpReputationScore('1.1.1.1');
20
+ expect(score).toBe(0);
21
+ });
22
+
23
+ it('should correctly increment and decrement score values', async () => {
24
+ const ip = '192.168.1.1';
25
+ await __internal.updateIpReputationScore(ip, 30);
26
+ let score = await __internal.getIpReputationScore(ip);
27
+ expect(score).toBe(30);
28
+
29
+ await __internal.updateIpReputationScore(ip, -10);
30
+ score = await __internal.getIpReputationScore(ip);
31
+ expect(score).toBe(20);
32
+ });
33
+
34
+ it('should clamp the reputation score within bounds [0, 100]', async () => {
35
+ const ip = '10.0.0.1';
36
+ await __internal.updateIpReputationScore(ip, 150);
37
+ let score = await __internal.getIpReputationScore(ip);
38
+ expect(score).toBe(100);
39
+
40
+ await __internal.updateIpReputationScore(ip, -200);
41
+ score = await __internal.getIpReputationScore(ip);
42
+ expect(score).toBe(0);
43
+ });
44
+
45
+ it('should apply passive decay of 2 points per hour of inactivity', async () => {
46
+ const ip = '172.16.0.1';
47
+ const now = Date.now();
48
+
49
+ // Simulation d'un score de 50 mis à jour il y a 3 heures (3h * 2 points/heure = 6 points de perte)
50
+ await mockStore.set(`ip-reputation:${ip}`, {
51
+ score: 50,
52
+ lastUpdate: now - (3 * 60 * 60 * 1000)
53
+ });
54
+
55
+ const score = await __internal.getIpReputationScore(ip);
56
+ expect(score).toBe(44);
57
+ });
58
+
59
+ it('should integrate ipReputationScore into the final score calculation', async () => {
60
+ const ip = '1.2.3.4';
61
+ await __internal.updateIpReputationScore(ip, 60); // Set IP reputation to 60
62
+
63
+ const mockSecurityConfig = {
64
+ weights: {
65
+ ipReputationScore: 0.5, // Give it a weight
66
+ historyScore: 0.0, // Other weights to 0 for isolation
67
+ rotationScore: 0.0,
68
+ headerAnomalyScore: 0.0,
69
+ requestPatternScore: 0.0,
70
+ inconsistencyScore: 0.0,
71
+ honeypotScore: 0.0,
72
+ behaviorScore: 0.0,
73
+ botScore: 0.0,
74
+ crossLayerInconsistencyScore: 0.0,
75
+ tlsSpoofingScore: 0.0,
76
+ timeInconsistencyScore: 0.0,
77
+ clickVarianceScore: 0.0,
78
+ clientHintsInconsistencyScore: 0.0,
79
+ subnetScore: 0.0,
80
+ },
81
+ thresholds: { low: 0, medium: 0, high: 0, block: 100 }, // Irrelevant for this test
82
+ };
83
+
84
+ const ipRepScore = await __internal.getIpReputationScore(ip);
85
+ const suspicionVector = { ipReputationScore: ipRepScore };
86
+
87
+ const engine = new FingerprintEngine(mockSecurityConfig);
88
+ const finalScore = engine.calculateFinalScore(suspicionVector);
89
+
90
+ // Expected score: 60 (ipRepScore) * 0.5 (weight) = 30
91
+ expect(finalScore).toBe(30.0);
92
+ });
93
+
94
+ it('should return correct subnet for IPv4 and IPv6', async () => {
95
+ const ipv4Subnet = __internal.getIpSubnet('192.168.1.50', 24, 48);
96
+ expect(ipv4Subnet).toBe('192.168.1.0/24');
97
+
98
+ const ipv6Subnet = __internal.getIpSubnet('2001:db8:abcd:12::1', 24, 48);
99
+ expect(ipv6Subnet).toBe('2001:db8:abcd:0:0:0:0:0/48');
100
+ });
101
+
102
+ it('should calculate client hints inconsistency score correctly', async () => {
103
+ const mockContextMismatch = {
104
+ headers: {
105
+ 'user-agent': 'Mozilla/5.0 Firefox/117.0',
106
+ 'sec-ch-ua': '"Google Chrome";v="117"'
107
+ }
108
+ };
109
+ const scoreMismatch = __internal.getClientHintsInconsistencyScore(mockContextMismatch);
110
+ expect(scoreMismatch.clientHintsInconsistencyScore).toBe(90);
111
+
112
+ const mockContextVersionDrift = {
113
+ headers: {
114
+ 'user-agent': 'Mozilla/5.0 Chrome/110.0',
115
+ 'sec-ch-ua': '"Google Chrome";v="117"'
116
+ }
117
+ };
118
+ const scoreVersionDrift = __internal.getClientHintsInconsistencyScore(mockContextVersionDrift);
119
+ expect(scoreVersionDrift.clientHintsInconsistencyScore).toBe(80);
120
+
121
+ const mockContextFullVersionMismatch = {
122
+ headers: {
123
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.1.2 Safari/537.36',
124
+ 'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
125
+ 'sec-ch-ua-full-version-list': '"Not_A Brand";v="8.0.0.0", "Chromium";v="120.0.1.3", "Google Chrome";v="120.0.1.3"'
126
+ }
127
+ };
128
+ const scoreFullVersionMismatch = __internal.getClientHintsInconsistencyScore(mockContextFullVersionMismatch);
129
+ expect(scoreFullVersionMismatch.clientHintsInconsistencyScore).toBe(85);
130
+ });
131
+
132
+ it('should integrate subnet history into subnet score', async () => {
133
+ const mockContext = { clientIp: '192.168.1.50' };
134
+ let score = await __internal.getSubnetScore(mockContext);
135
+ expect(score.subnetScore).toBe(0);
136
+ for (let i = 1; i <= 12; i++) {
137
+ await __internal.updateSubnetMetrics(mockContext, `dev-${i}`, 40);
138
+ }
139
+ score = await __internal.getSubnetScore(mockContext);
140
+ expect(score.subnetScore).toBeGreaterThan(0);
141
+ });
132
142
  });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __internal } from '../fingerprint.js';
3
+
4
+ const { parseTcpSyn, classifyTcpOs, getTcpAnomalyScore } = __internal;
5
+
6
+ describe('Passive TCP/IP Fingerprinting (Type p0f)', () => {
7
+
8
+ // Mock d'un paquet IP + TCP SYN d'un système Linux (TTL=64, WS=7)
9
+ const mockLinuxSynPacket = Buffer.from([
10
+ 0x45, 0x00, 0x00, 0x3c, 0x1a, 0x2b, 0x40, 0x00,
11
+ 0x40, 0x06, 0x3c, 0x1a, 0x7f, 0x00, 0x00, 0x01, // TTL=0x40 (64)
12
+ 0x7f, 0x00, 0x00, 0x01,
13
+ 0x1f, 0x90, 0x00, 0x50, 0x00, 0x00, 0x00, 0x01,
14
+ 0x00, 0x00, 0x00, 0x00, 0xa0, 0x02, 0x72, 0x10, // Window=0x7210 (29200), TCP header len = 40 (0xa0)
15
+ 0x3c, 0x1a, 0x00, 0x00,
16
+ 0x02, 0x04, 0x05, 0xb4, // Option MSS: 1460 (0x05b4)
17
+ 0x04, 0x02, // Option SACK Permitted
18
+ 0x01, // NOP
19
+ 0x03, 0x03, 0x07 // Option WS: 7
20
+ ]);
21
+
22
+ // Mock d'un paquet IP + TCP SYN d'un système Windows (TTL=128, WS=8)
23
+ const mockWindowsSynPacket = Buffer.from([
24
+ 0x45, 0x00, 0x00, 0x3c, 0x1a, 0x2b, 0x40, 0x00,
25
+ 0x80, 0x06, 0x3c, 0x1a, 0x7f, 0x00, 0x00, 0x01, // TTL=0x80 (128)
26
+ 0x7f, 0x00, 0x00, 0x01,
27
+ 0x1f, 0x90, 0x00, 0x50, 0x00, 0x00, 0x00, 0x01,
28
+ 0x00, 0x00, 0x00, 0x00, 0xa0, 0x02, 0xfa, 0xf0, // Window=64240 (0xfaf0)
29
+ 0x3c, 0x1a, 0x00, 0x00,
30
+ 0x02, 0x04, 0x05, 0xb4, // Option MSS: 1460 (0x05b4)
31
+ 0x04, 0x02, // Option SACK Permitted
32
+ 0x01, // NOP
33
+ 0x03, 0x03, 0x08 // Option WS: 8
34
+ ]);
35
+
36
+ it('should correctly parse raw TCP SYN binary packets', () => {
37
+ const linuxFp = parseTcpSyn(mockLinuxSynPacket);
38
+ expect(linuxFp).not.toBeNull();
39
+ expect(linuxFp.ttl).toBe(64);
40
+ expect(linuxFp.windowSize).toBe(29200);
41
+ expect(linuxFp.mss).toBe(1460);
42
+ expect(linuxFp.ws).toBe(7);
43
+ expect(linuxFp.sack).toBe(true);
44
+
45
+ const windowsFp = parseTcpSyn(mockWindowsSynPacket);
46
+ expect(windowsFp.ttl).toBe(128);
47
+ expect(windowsFp.windowSize).toBe(64240);
48
+ expect(windowsFp.ws).toBe(8);
49
+ });
50
+
51
+ it('should classify OS correctly based on parsed parameters', () => {
52
+ const linuxFp = parseTcpSyn(mockLinuxSynPacket);
53
+ expect(classifyTcpOs(linuxFp)).toBe('Linux');
54
+
55
+ const windowsFp = parseTcpSyn(mockWindowsSynPacket);
56
+ expect(classifyTcpOs(windowsFp)).toBe('Windows');
57
+ });
58
+
59
+ it('should calculate correct TCP/IP anomaly scores', () => {
60
+ // Cas 1 : Le User-Agent prétend être Windows, mais la pile TCP/IP est Linux
61
+ const contextAnomaly = {
62
+ headers: {
63
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0',
64
+ },
65
+ rawTcpBinary: mockLinuxSynPacket
66
+ };
67
+ expect(getTcpAnomalyScore(contextAnomaly).tcpAnomalyScore).toBe(80.1);
68
+
69
+ // Cas 2 : Cohérence complète (UA Windows et pile TCP/IP Windows)
70
+ const contextCoherent = {
71
+ headers: {
72
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0',
73
+ },
74
+ rawTcpBinary: mockWindowsSynPacket
75
+ };
76
+ expect(getTcpAnomalyScore(contextCoherent).tcpAnomalyScore).toBe(0);
77
+ });
78
+ });
@@ -77,7 +77,7 @@ class AutoTuner
77
77
 
78
78
  echo sprintf("[AutoTuning] Démarrage du cycle d'optimisation complet avec %d points de données assainis.\n", count($sanitizedData));
79
79
 
80
- $paretoFront = OptimizationOperators::solveFullSecurityTuning(['trafficData' => $sanitizedData]);
80
+ $paretoFront = OptimizationOperators::solveFullSecurityTuning(['trafficData' => $sanitizedData, 'currentConfig' => $this->securityConfig], []);
81
81
 
82
82
  if (empty($paretoFront)) {
83
83
  echo "[AutoTuning] L'optimisation n'a retourné aucune solution.\n";
@@ -5,6 +5,7 @@ declare(strict_types=1);
5
5
  namespace Anonympins\Fingerprint\Challenge;
6
6
 
7
7
  use Anonympins\Fingerprint\Store\StoreManager;
8
+ use Anonympins\Fingerprint\FingerprintBuilder;
8
9
  use Anonympins\Fingerprint\Utils\BigInt;
9
10
  use Anonympins\Fingerprint\Utils\RequestUtils;
10
11
 
@@ -23,6 +24,65 @@ class ChallengeUtils
23
24
  '/.git/config_{RANDOM}'
24
25
  ];
25
26
 
27
+ private static function imul(int $a, int $b): int
28
+ {
29
+ $ah = ($a >> 16) & 0xffff;
30
+ $al = $a & 0xffff;
31
+ $bh = ($b >> 16) & 0xffff;
32
+ $bl = $b & 0xffff;
33
+ $lo = $al * $bl;
34
+ $hi = (($lo >> 16) + ($al * $bh) + ($ah * $bl)) & 0xffff;
35
+ return (($hi << 16) | ($lo & 0xffff)) | 0;
36
+ }
37
+
38
+ private static function generateBlock(string $seed, int $blockIndex, int $blockSize = 1024): string
39
+ {
40
+ $block = str_repeat("\x00", $blockSize);
41
+ $h = FingerprintBuilder::cyrb53($seed . ":" . $blockIndex);
42
+
43
+ $h_int = (int)bcmod($h, '4294967296');
44
+ for ($i = 0; $i < $blockSize; $i++) {
45
+ $h_int = self::imul($h_int ^ $i, 1597334677);
46
+ $block[$i] = chr($h_int & 0xff);
47
+ }
48
+ return $block;
49
+ }
50
+
51
+ public static function generateSpaceChallenge(string $clientIp, string $nonce, float $suspicionFactor, string $originalUrl, array $securityConfig): array
52
+ {
53
+ $pospaceConfig = $securityConfig['pospace'] ?? [];
54
+ $sizeMb = $pospaceConfig['sizeMb'] ?? 100;
55
+ $numQueries = $pospaceConfig['numQueries'] ?? 10;
56
+
57
+ $queries = [];
58
+ $maxBlocks = $sizeMb * 1024;
59
+ while (count($queries) < $numQueries) {
60
+ $idx = random_int(0, $maxBlocks - 1);
61
+ if (!in_array($idx, $queries, true)) {
62
+ $queries[] = $idx;
63
+ }
64
+ }
65
+
66
+ return [
67
+ 'type' => 'pospace',
68
+ 'nonce' => $nonce,
69
+ 'sizeMb' => $sizeMb,
70
+ 'queries' => $queries,
71
+ 'path' => $originalUrl
72
+ ];
73
+ }
74
+
75
+ public static function verifySpacePoW(string $nonce, string $solution, array $queries, string $seed, string $clientSecret): bool
76
+ {
77
+ $combined = '';
78
+ foreach ($queries as $idx) {
79
+ $combined .= self::generateBlock($seed, (int)$idx);
80
+ }
81
+ $finalBlock = $combined . $nonce . ":" . $clientSecret;
82
+ $hash = hash('sha256', $finalBlock);
83
+ return hash_equals($hash, $solution);
84
+ }
85
+
26
86
  /**
27
87
  * Récupère la clé secrète pour les PoW depuis les variables d'environnement.
28
88
  */
@@ -35,6 +95,52 @@ class ChallengeUtils
35
95
  return $secret ?: "fallback-dev-secret-32-chars-minimum";
36
96
  }
37
97
 
98
+ /**
99
+ * Génère un ticket stateless chiffré et signé contenant le contexte d'autorisation.
100
+ * @param array $payload
101
+ * @return string
102
+ */
103
+ public static function generateStatelessTicket(array $payload): string
104
+ {
105
+ $key = hash('sha256', self::getPowSecret(), true);
106
+ $iv = random_bytes(16);
107
+ $encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
108
+ $signature = hash_hmac('sha256', $iv . $encrypted, $key, true);
109
+
110
+ return rtrim(strtr(base64_encode($iv), '+/', '-_'), '=') . '.' .
111
+ rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
112
+ rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
113
+ }
114
+
115
+ /**
116
+ * Décode et valide un ticket stateless chiffré et signé.
117
+ * @param string $ticket
118
+ * @return array|null
119
+ */
120
+ public static function parseStatelessTicket(string $ticket): ?array
121
+ {
122
+ $parts = explode('.', $ticket);
123
+ if (count($parts) !== 3) {
124
+ return null;
125
+ }
126
+ $base64UrlDecode = function ($input) {
127
+ return base64_decode(strtr($input, '-_', '+/'));
128
+ };
129
+ $iv = $base64UrlDecode($parts[0]);
130
+ $encrypted = $base64UrlDecode($parts[1]);
131
+ $signature = $base64UrlDecode($parts[2]);
132
+ if (!$iv || !$encrypted || !$signature || strlen($iv) !== 16) {
133
+ return null;
134
+ }
135
+ $key = hash('sha256', self::getPowSecret(), true);
136
+ $expectedSignature = hash_hmac('sha256', $iv . $encrypted, $key, true);
137
+ if (!hash_equals($expectedSignature, $signature)) {
138
+ return null;
139
+ }
140
+ $decrypted = openssl_decrypt($encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
141
+ return $decrypted !== false ? json_decode($decrypted, true) : null;
142
+ }
143
+
38
144
  /**
39
145
  * Vérifie si un ticket de passage est valide (supporte les tickets opaques via store et le fallback legacy).
40
146
  */
@@ -49,6 +155,31 @@ class ChallengeUtils
49
155
  return false;
50
156
  }
51
157
 
158
+ // Tentative de validation stateless d'abord
159
+ $ticketData = self::parseStatelessTicket($ticket);
160
+ if ($ticketData !== null) {
161
+ $expiry = $ticketData['expiry'] ?? null;
162
+ $originalIp = $ticketData['originalIp'] ?? null;
163
+ $storedDeviceId = $ticketData['deviceId'] ?? '';
164
+ $storedDeviceHash = $ticketData['deviceHash'] ?? '';
165
+
166
+ if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
167
+ return false;
168
+ }
169
+ if ($ip === $originalIp) {
170
+ return true;
171
+ }
172
+ $currentSubnet = RequestUtils::getIpSubnet($ip);
173
+ $originalSubnet = RequestUtils::getIpSubnet($originalIp);
174
+ if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
175
+ return true;
176
+ }
177
+ if (!$allowCrossNetworkRoaming) {
178
+ return false;
179
+ }
180
+ return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
181
+ }
182
+
52
183
  $store = StoreManager::getStore();
53
184
  $ticketData = $store->get("ticket:{$ticket}");
54
185
 
@@ -152,27 +283,21 @@ class ChallengeUtils
152
283
  $finalBlock = $baseBlock . $solution;
153
284
  $hash = hash('sha256', $finalBlock);
154
285
 
155
- $hashAsInt = BigInt::fromHex($hash);
156
- $targetAsInt = BigInt::fromHex($cpuTargetHex);
157
-
158
- $isValid = $hashAsInt->compareTo($targetAsInt) < 0;
286
+ // Pad target to 64 hex characters to allow direct O(1) lexicographical comparison
287
+ $paddedTarget = str_pad($cpuTargetHex, 64, '0', STR_PAD_LEFT);
288
+ $isValid = strcmp($hash, $paddedTarget) < 0;
159
289
 
160
290
  if ($isValid) {
161
291
  error_log('[FP Server Verify] CPU PoW verification PASSED.');
162
292
 
163
- // Génération d'un jeton opaque et unique
164
- $ticketId = bin2hex(random_bytes(16));
165
293
  $expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
166
-
167
- $store = StoreManager::getStore();
168
- $store->set("ticket:{$ticketId}", [
294
+ $payload = [
169
295
  'expiry' => $expiry,
170
296
  'originalIp' => $clientIp,
171
297
  'deviceId' => $deviceId,
172
298
  'deviceHash' => $deviceHash
173
- ], (int)ceil($ticketTtl / 1000));
174
-
175
- return $ticketId;
299
+ ];
300
+ return self::generateStatelessTicket($payload);
176
301
  }
177
302
 
178
303
  // Log details on failure
@@ -336,6 +461,43 @@ class ChallengeUtils
336
461
  return file_get_contents($solverPath) ?: '';
337
462
  }
338
463
 
464
+ public static function generateSpaceChallengePage(array $challengeDetails, string $clientSecret, array $securityConfig): string
465
+ {
466
+ $nonce = $challengeDetails['nonce'];
467
+ $sizeMb = $challengeDetails['sizeMb'];
468
+ $queries = $challengeDetails['queries'];
469
+ $path = $challengeDetails['path'];
470
+
471
+ $solverCode = self::getPowSolverCode();
472
+ $queriesJson = json_encode($queries);
473
+
474
+ $challengeScript = <<<JS
475
+ async function solve() {
476
+ const nonce = "{$nonce}";
477
+ const path = "{$path}";
478
+ const clientSecret = "{$clientSecret}";
479
+ const queries = {$queriesJson};
480
+ const sizeMb = {$sizeMb};
481
+
482
+ document.getElementById('loader').innerText = '⚙️ Checking persistent local storage...';
483
+ await new Promise(r => setTimeout(r, 10));
484
+
485
+ try {
486
+ await window.initializeSpace(nonce + ":" + clientSecret, sizeMb);
487
+ document.getElementById('loader').innerText = '⚙️ Generating Proof of Space...';
488
+ const hash = await window.solveSpaceChallenge(nonce + ":" + clientSecret, queries, nonce, clientSecret);
489
+
490
+ window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash;
491
+ } catch(e) {
492
+ document.getElementById('loader').innerText = "Error initializing local storage: " + e.message;
493
+ }
494
+ }
495
+ solve();
496
+ JS;
497
+
498
+ return "<html><head><title>Security Check</title></head><body style=\"font-family:sans-serif; text-align:center; padding-top:50px;\"><h1>Security Check (Level 2)</h1><p>We are verifying your storage allocation. This may take a few seconds on first load.</p><div id=\"loader\" style=\"margin:20px;\">⚙️ Initializing storage space...</div><script>{$solverCode}</script><script>{$challengeScript}</script></body></html>";
499
+ }
500
+
339
501
  /**
340
502
  * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
341
503
  * @param array $cpuChallengeDetails
@@ -40,6 +40,8 @@ class SecurityProfiles
40
40
  'clickVarianceScore' => 0.6, // Poids pour la variance des clics
41
41
  'subnetScore' => 0.5, // Pénalise les sous-réseaux IP avec une activité suspecte agrégée
42
42
  'botnetClusterScore' => 0.6, // NOUVEAU: Poids pour le clustering botnet
43
+ 'tcpAnomalyScore' => 0.8, // NEW: Anomalie de pile TCP/IP
44
+
43
45
  ],
44
46
  'thresholds' => ['low' => 20, 'medium' => 45, 'high' => 75, 'block' => 95],
45
47
  'patterns' => [
@@ -84,6 +86,8 @@ class SecurityProfiles
84
86
  'clickVarianceScore' => 0.7, // High weight for click variance
85
87
  'subnetScore' => 0.7, // Poids plus élevé en mode strict
86
88
  'botnetClusterScore' => 0.8, // NOUVEAU: Poids pour le clustering botnet
89
+ 'tcpAnomalyScore' => 1.0, // NEW: Anomalie de pile TCP/IP
90
+
87
91
  ],
88
92
  'thresholds' => ['low' => 10, 'medium' => 35, 'high' => 65, 'block' => 90],
89
93
  'patterns' => [
@@ -128,6 +132,8 @@ class SecurityProfiles
128
132
  'clickVarianceScore' => 0.3, // Low weight as not applicable to APIs
129
133
  'subnetScore' => 0.8, // Très important pour les API pour détecter les botnets
130
134
  'botnetClusterScore' => 0.7, // NOUVEAU: Poids pour le clustering botnet
135
+ 'tcpAnomalyScore' => 0.8, // NEW: Anomalie de pile TCP/IP
136
+
131
137
  ],
132
138
  'thresholds' => ['low' => 25, 'medium' => 50, 'high' => 80, 'block' => 95],
133
139
  'patterns' => [
@@ -174,6 +180,8 @@ class SecurityProfiles
174
180
  'clickVarianceScore' => 0.5, // Moderate weight for click variance
175
181
  'subnetScore' => 0.4, // Utile contre le spam de commentaires coordonné
176
182
  'botnetClusterScore' => 0.5, // NOUVEAU: Poids pour le clustering botnet
183
+ 'tcpAnomalyScore' => 0.5, // NEW: Anomalie de pile TCP/IP
184
+
177
185
  ],
178
186
  'thresholds' => ['low' => 25, 'medium' => 55, 'high' => 80, 'block' => 95],
179
187
  'patterns' => [
@@ -219,6 +227,8 @@ class SecurityProfiles
219
227
  'clickVarianceScore' => 0.8, // Very high weight for click variance
220
228
  'subnetScore' => 0.9, // Crucial contre les attaques de scalping distribuées
221
229
  'botnetClusterScore' => 0.9, // NOUVEAU: Poids pour le clustering botnet
230
+ 'tcpAnomalyScore' => 0.9, // NEW: Anomalie de pile TCP/IP
231
+
222
232
  ],
223
233
  'thresholds' => ['low' => 15, 'medium' => 40, 'high' => 70, 'block' => 90],
224
234
  'patterns' => [