@anonympins/fingerprint 0.4.6 → 0.5.0

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,5 +1,5 @@
1
1
  import {afterEach, assert, beforeEach, describe, expect, it, test, vi} from 'vitest';
2
- import {createHash, createHmac} from 'node:crypto';
2
+ import {createHash, createHmac, generateKeyPairSync} from 'node:crypto';
3
3
  import {solveCpuTargetInline, solveMemory} from '../pow.solver.js';
4
4
  import {readFileSync} from 'node:fs';
5
5
  import {cyrb53, FingerprintBuilder} from '../fingerprint.builder.js';
@@ -1092,6 +1092,63 @@ describe('Fingerprint & PoW Security Suite', () => {
1092
1092
  stopThresholdAutoTuning(); // Ensure cleanup after each test
1093
1093
  });
1094
1094
 
1095
+ test('should prune traffic data based on time, size, clearAfterTuning and invoke onCleanup', async () => {
1096
+ const trafficData = [
1097
+ { type: 'challenge_solved', timestamp: Date.now() - 10000 }, // 10s old
1098
+ { type: 'challenge_solved', timestamp: Date.now() - 5000 }, // 5s old
1099
+ { type: 'challenge_solved', timestamp: Date.now() } // fresh
1100
+ ];
1101
+
1102
+ const securityConfig = {
1103
+ thresholds: { low: 20, medium: 45, high: 75, block: 95 },
1104
+ weights: { historyScore: 1 },
1105
+ patterns: {}
1106
+ };
1107
+
1108
+ const cleanedLogs = [];
1109
+ const onCleanup = (removed) => {
1110
+ cleanedLogs.push(...removed);
1111
+ };
1112
+
1113
+ // Test 1: Time-based pruning (older than 8 seconds)
1114
+ __internal.pruneTrafficData(trafficData, 10, 8000, onCleanup);
1115
+
1116
+ expect(trafficData.length).toBe(2);
1117
+ expect(cleanedLogs.length).toBe(1);
1118
+ expect(cleanedLogs[0].timestamp).toBeLessThan(Date.now() - 8000);
1119
+
1120
+ // Test 2: Size-based pruning (max size = 1)
1121
+ __internal.pruneTrafficData(trafficData, 1, 0, onCleanup);
1122
+ expect(trafficData.length).toBe(1);
1123
+ expect(cleanedLogs.length).toBe(2);
1124
+
1125
+ // Test 3: Clear after tuning
1126
+ const autoTuningTraffic = [];
1127
+ for (let i = 0; i < 110; i++) {
1128
+ autoTuningTraffic.push({ type: 'challenge_solved', timestamp: Date.now(), deviceId: `dev-solved-${i}` });
1129
+ autoTuningTraffic.push({ type: 'request_passed', timestamp: Date.now(), deviceId: `dev-passed-${i}` });
1130
+ }
1131
+
1132
+ const tuningCleaned = [];
1133
+ startThresholdAutoTuning({
1134
+ securityConfig,
1135
+ trafficData: autoTuningTraffic,
1136
+ interval: 10000,
1137
+ minDataPoints: 100,
1138
+ maxDataPoints: 500,
1139
+ clearAfterTuning: true,
1140
+ onCleanup: (removed) => tuningCleaned.push(...removed)
1141
+ });
1142
+
1143
+ const intervalCallback = setIntervalSpy.mock.calls[0][0];
1144
+ intervalCallback();
1145
+
1146
+ expect(autoTuningTraffic.length).toBe(0);
1147
+ expect(tuningCleaned.length).toBeGreaterThan(0);
1148
+
1149
+ stopThresholdAutoTuning();
1150
+ });
1151
+
1095
1152
  test('should start, run an optimization cycle, and update thresholds', () => {
1096
1153
  const trafficData = [];
1097
1154
  const securityConfig = {
@@ -2426,6 +2483,80 @@ describe('Additional Suspicion Vectors Coverage', () => {
2426
2483
  });
2427
2484
  });
2428
2485
 
2486
+ describe('Ed25519 Asymmetric Tickets', () => {
2487
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519', {
2488
+ privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
2489
+ publicKeyEncoding: { format: 'pem', type: 'spki' }
2490
+ });
2491
+ const privateKeyPem = privateKey;
2492
+ const publicKeyPem = publicKey;
2493
+
2494
+ beforeEach(() => {
2495
+ process.env.ED25519_PRIVATE_KEY = privateKeyPem;
2496
+ process.env.ED25519_PUBLIC_KEY = publicKeyPem;
2497
+ });
2498
+
2499
+ afterEach(() => {
2500
+ delete process.env.ED25519_PRIVATE_KEY;
2501
+ delete process.env.ED25519_PUBLIC_KEY;
2502
+ });
2503
+
2504
+ it('should generate and validate an Ed25519 ticket successfully', async () => {
2505
+ const payload = {
2506
+ expiry: Date.now() + 3600000,
2507
+ originalIp: '127.0.0.1',
2508
+ deviceId: 'device-123',
2509
+ deviceHash: 'hash-abc'
2510
+ };
2511
+
2512
+ const ticket = fingerprint.generateStatelessTicket(payload);
2513
+ expect(ticket).toBeDefined();
2514
+ expect(ticket.startsWith('ed25519.')).toBe(true);
2515
+
2516
+ const isValid = await fingerprint.isTicketValid('127.0.0.1', ticket, 'device-123', 'hash-abc');
2517
+ expect(isValid).toBe(true);
2518
+
2519
+ const isDiffIpValid = await fingerprint.isTicketValid('192.168.1.1', ticket, 'device-123', 'hash-abc', false);
2520
+ expect(isDiffIpValid).toBe(false);
2521
+ });
2522
+
2523
+ test('Cooperative PoSpace Workflow', async () => {
2524
+ const clientIp = '127.0.0.1';
2525
+ const nodeIdA = 'node-a';
2526
+ const seedA = 'seed-a';
2527
+
2528
+ // 1. Enregistrement du nœud A (pair)
2529
+ await __internal.registerCooperativeNode(clientIp, nodeIdA, seedA);
2530
+
2531
+ // 2. Recherche de pair pour le nœud B (dans le même sous-réseau)
2532
+ const peer = await __internal.findPeerInSubnet(clientIp, 'node-b');
2533
+ expect(peer).not.toBeNull();
2534
+ expect(peer.nodeId).toBe(nodeIdA);
2535
+ expect(peer.seed).toBe(seedA);
2536
+
2537
+ // 3. Demande de bloc du nœud B vers le nœud A
2538
+ const paramsReq = {
2539
+ coop_op: 'request_peer_block',
2540
+ node_id: 'node-b',
2541
+ peer_id: 'node-a',
2542
+ block_idx: '42',
2543
+ req_id: 'req-123'
2544
+ };
2545
+ const resReq = await __internal.handleCooperativeRequest(paramsReq, clientIp);
2546
+ expect(resReq.status).toBe('queued');
2547
+
2548
+ // 4. Récupération de la demande par le nœud A
2549
+ const paramsPoll = {
2550
+ coop_op: 'poll_requests',
2551
+ node_id: 'node-a'
2552
+ };
2553
+ const resPoll = await __internal.handleCooperativeRequest(paramsPoll, clientIp);
2554
+ expect(resPoll.requests.length).toBe(1);
2555
+ expect(resPoll.requests[0].req_id).toBe('req-123');
2556
+ expect(resPoll.requests[0].block_idx).toBe(42);
2557
+ });
2558
+ });
2559
+
2429
2560
  describe('Botnet Cluster Scoring (Node.js)', () => {
2430
2561
  const inMemoryStore = {
2431
2562
  _map: new Map(),
@@ -1,6 +1,7 @@
1
1
  import crypto from 'crypto';
2
2
  import {getTlsSpoofingScore, parseJa3} from '../fingerprint.js';
3
3
  import {vi} from 'vitest';
4
+ import { verifyZkpProof, decodePolymorphicFingerprint, deepMerge, getHeaderSignature, parseJa3, modPow, hashNetwork, normalizeReferer, isPrivateIp, parseUserAgent } from "../../js/fingerprint.utils.js";
4
5
 
5
6
  describe('JA3 Anomaly Detector (Node.js)', () => {
6
7
 
@@ -25,6 +25,11 @@ class AutoTuner
25
25
 
26
26
  private int $minDataPoints;
27
27
  private int $maxDataPoints;
28
+ private ?int $maxAgeMs;
29
+ private bool $clearAfterTuning;
30
+ /** @var ?callable */
31
+ private $onCleanup;
32
+ private ?string $savePath;
28
33
 
29
34
  /**
30
35
  * @var ?array<string, mixed> La dernière meilleure solution trouvée par l'optimiseur.
@@ -42,6 +47,48 @@ class AutoTuner
42
47
  $this->trafficData = &$trafficData;
43
48
  $this->minDataPoints = $options['minDataPoints'] ?? 200;
44
49
  $this->maxDataPoints = $options['maxDataPoints'] ?? 10000;
50
+ $this->maxAgeMs = $options['maxAgeMs'] ?? null;
51
+ $this->clearAfterTuning = $options['clearAfterTuning'] ?? false;
52
+ $this->onCleanup = $options['onCleanup'] ?? null;
53
+ $this->savePath = $options['savePath'] ?? null;
54
+ }
55
+
56
+ /**
57
+ * Prunes old or excess traffic logs to prevent memory leaks.
58
+ */
59
+ private function pruneTrafficData(): void
60
+ {
61
+ $now = (int)(microtime(true) * 1000);
62
+ $removed = [];
63
+
64
+ // 1. Expire par temps (maxAgeMs)
65
+ if ($this->maxAgeMs !== null && $this->maxAgeMs > 0) {
66
+ $threshold = $now - $this->maxAgeMs;
67
+ foreach ($this->trafficData as $key => $log) {
68
+ $logTs = $log['timestamp'] ?? $log['requestTimestamp'] ?? $now;
69
+ if ($logTs < $threshold) {
70
+ $removed[] = $log;
71
+ unset($this->trafficData[$key]);
72
+ }
73
+ }
74
+ $this->trafficData = array_values($this->trafficData);
75
+ }
76
+
77
+ // 2. Politique de taille maximale (maxDataPoints)
78
+ if ($this->maxDataPoints > 0 && count($this->trafficData) > $this->maxDataPoints) {
79
+ $overflowCount = count($this->trafficData) - $this->maxDataPoints;
80
+ $spliced = array_splice($this->trafficData, 0, $overflowCount);
81
+ $removed = array_merge($removed, $spliced);
82
+ }
83
+
84
+ // 3. Invocation du callback onCleanup
85
+ if ($this->onCleanup !== null && is_callable($this->onCleanup) && !empty($removed)) {
86
+ try {
87
+ call_user_func($this->onCleanup, $removed);
88
+ } catch (\Throwable $e) {
89
+ error_log("[AutoTuning] Error in onCleanup callback: " . $e->getMessage());
90
+ }
91
+ }
45
92
  }
46
93
 
47
94
  /**
@@ -49,6 +96,8 @@ class AutoTuner
49
96
  */
50
97
  public function runOptimizationCycle(): void
51
98
  {
99
+ $this->pruneTrafficData();
100
+
52
101
  $sanitizedData = RequestUtils::sanitizeTrafficData($this->trafficData);
53
102
 
54
103
  $highConfidenceLogs = count(array_filter(
@@ -176,6 +225,28 @@ class AutoTuner
176
225
  echo "[AutoTuning] Nouveaux seuils : " . json_encode($this->securityConfig['thresholds']) . "\n";
177
226
  echo "[AutoTuning] Nouveaux poids : " . json_encode($this->securityConfig['weights']) . "\n";
178
227
  echo "[AutoTuning] Nouveaux patterns : " . json_encode($this->securityConfig['patterns']) . "\n";
228
+
229
+ // NOUVEAU: Sauvegarder la meilleure configuration si un chemin est fourni.
230
+ if ($this->savePath !== null) {
231
+ try {
232
+ file_put_contents($this->savePath, json_encode($bestSolution['solution'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
233
+ echo "[AutoTuning] Meilleure configuration sauvegardée dans : {$this->savePath}\n";
234
+ } catch (\Throwable $e) {
235
+ error_log("[AutoTuning] Erreur lors de la sauvegarde de la configuration optimisée : " . $e->getMessage());
236
+ }
237
+ }
238
+
239
+ if ($this->clearAfterTuning) {
240
+ $cleared = array_splice($this->trafficData, 0);
241
+ if ($this->onCleanup !== null && is_callable($this->onCleanup) && !empty($cleared)) {
242
+ try {
243
+ call_user_func($this->onCleanup, $cleared);
244
+ } catch (\Throwable $e) {
245
+ error_log("[AutoTuning] Error in onCleanup callback after clearing: " . $e->getMessage());
246
+ }
247
+ }
248
+ echo sprintf("[AutoTuning] Explicitly cleared %d processed traffic data points.\n", count($cleared));
249
+ }
179
250
  }
180
251
 
181
252
  /**
@@ -48,6 +48,123 @@ class ChallengeUtils
48
48
  return $block;
49
49
  }
50
50
 
51
+ public static function registerCooperativeNode(string $clientIp, string $nodeId, string $seed): void
52
+ {
53
+ $subnet = RequestUtils::getIpSubnet($clientIp);
54
+ if ($subnet === null) {
55
+ return;
56
+ }
57
+ $store = StoreManager::getStore();
58
+ $key = "coop-pospace:subnet:{$subnet}";
59
+ $nodes = $store->get($key) ?? [];
60
+
61
+ $now = time();
62
+ // Nettoyage des nœuds expirés (vieux de plus de 2 minutes)
63
+ $nodes = array_filter($nodes, fn($n) => ($now - $n['timestamp']) < 120);
64
+
65
+ $nodes[$nodeId] = [
66
+ 'nodeId' => $nodeId,
67
+ 'seed' => $seed,
68
+ 'timestamp' => $now
69
+ ];
70
+
71
+ $store->set($key, $nodes, 120);
72
+ }
73
+
74
+ public static function findPeerInSubnet(string $clientIp, string $excludeNodeId): ?array
75
+ {
76
+ $subnet = RequestUtils::getIpSubnet($clientIp);
77
+ if ($subnet === null) {
78
+ return null;
79
+ }
80
+ $store = StoreManager::getStore();
81
+ $key = "coop-pospace:subnet:{$subnet}";
82
+ $nodes = $store->get($key) ?? [];
83
+
84
+ $now = time();
85
+ $activePeers = [];
86
+ foreach ($nodes as $id => $node) {
87
+ if ($id !== $excludeNodeId && ($now - $node['timestamp']) < 120) {
88
+ $activePeers[] = $node;
89
+ }
90
+ }
91
+
92
+ if (empty($activePeers)) {
93
+ return null;
94
+ }
95
+
96
+ return $activePeers[array_rand($activePeers)];
97
+ }
98
+
99
+ public static function handleCooperativeRequest(array $params): ?array
100
+ {
101
+ $op = $params['coop_op'] ?? null;
102
+ if (!$op) {
103
+ return null;
104
+ }
105
+
106
+ $store = StoreManager::getStore();
107
+ $nodeId = $params['node_id'] ?? '';
108
+ if (empty($nodeId)) {
109
+ return ['error' => 'Missing node_id'];
110
+ }
111
+
112
+ switch ($op) {
113
+ case 'register':
114
+ $clientIp = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
115
+ $seed = $params['seed'] ?? '';
116
+ self::registerCooperativeNode($clientIp, $nodeId, $seed);
117
+ return ['status' => 'registered'];
118
+
119
+ case 'request_peer_block':
120
+ $peerId = $params['peer_id'] ?? '';
121
+ $blockIdx = (int)($params['block_idx'] ?? 0);
122
+ $requestId = $params['req_id'] ?? '';
123
+ if (empty($peerId) || empty($requestId)) {
124
+ return ['error' => 'Invalid parameters'];
125
+ }
126
+
127
+ $queueKey = "coop-mailbox:queue:{$peerId}";
128
+ $requests = $store->get($queueKey) ?? [];
129
+ $requests[] = [
130
+ 'req_id' => $requestId,
131
+ 'requester_id' => $nodeId,
132
+ 'block_idx' => $blockIdx
133
+ ];
134
+ $store->set($queueKey, $requests, 30);
135
+ return ['status' => 'queued'];
136
+
137
+ case 'poll_requests':
138
+ $queueKey = "coop-mailbox:queue:{$nodeId}";
139
+ $requests = $store->get($queueKey) ?? [];
140
+ $store->delete($queueKey);
141
+ return ['requests' => $requests];
142
+
143
+ case 'respond_block':
144
+ $requesterId = $params['requester_id'] ?? '';
145
+ $requestId = $params['req_id'] ?? '';
146
+ $blockData = $params['block_data'] ?? '';
147
+ if (empty($requesterId) || empty($requestId)) {
148
+ return ['error' => 'Invalid parameters'];
149
+ }
150
+
151
+ $responseKey = "coop-mailbox:res:{$requesterId}:{$requestId}";
152
+ $store->set($responseKey, ['block_data' => $blockData], 30);
153
+ return ['status' => 'delivered'];
154
+
155
+ case 'poll_response':
156
+ $requestId = $params['req_id'] ?? '';
157
+ $responseKey = "coop-mailbox:res:{$nodeId}:{$requestId}";
158
+ $data = $store->get($responseKey);
159
+ if ($data) {
160
+ $store->delete($responseKey);
161
+ return ['status' => 'ready', 'block_data' => $data['block_data']];
162
+ }
163
+ return ['status' => 'pending'];
164
+ }
165
+ return null;
166
+ }
167
+
51
168
  public static function generateSpaceChallenge(string $clientIp, string $nonce, float $suspicionFactor, string $originalUrl, array $securityConfig): array
52
169
  {
53
170
  $pospaceConfig = $securityConfig['pospace'] ?? [];
@@ -63,13 +180,29 @@ class ChallengeUtils
63
180
  }
64
181
  }
65
182
 
66
- return [
183
+ $challenge = [
67
184
  'type' => 'pospace',
68
185
  'nonce' => $nonce,
69
186
  'sizeMb' => $sizeMb,
70
187
  'queries' => $queries,
71
188
  'path' => $originalUrl
72
189
  ];
190
+
191
+ // Tentative de couplage coopératif avec un nœud du même sous-réseau
192
+ $peer = self::findPeerInSubnet($clientIp, $nonce);
193
+ if ($peer !== null) {
194
+ $challenge['peerId'] = $peer['nodeId'];
195
+ $challenge['peerBlockIdx'] = random_int(0, $maxBlocks - 1);
196
+
197
+ $store = StoreManager::getStore();
198
+ $store->set("coop-assoc:{$nonce}", [
199
+ 'peerNodeId' => $peer['nodeId'],
200
+ 'peerSeed' => $peer['seed'],
201
+ 'peerBlockIdx' => $challenge['peerBlockIdx']
202
+ ], 120);
203
+ }
204
+
205
+ return $challenge;
73
206
  }
74
207
 
75
208
  public static function verifySpacePoW(string $nonce, string $solution, array $queries, string $seed, string $clientSecret): bool
@@ -78,11 +211,48 @@ class ChallengeUtils
78
211
  foreach ($queries as $idx) {
79
212
  $combined .= self::generateBlock($seed, (int)$idx);
80
213
  }
214
+
215
+ // Vérification de la preuve coopérative
216
+ $store = StoreManager::getStore();
217
+ $assoc = $store->get("coop-assoc:{$nonce}");
218
+ if ($assoc !== null) {
219
+ $peerSeed = $assoc['peerSeed'] ?? null;
220
+ $peerBlockIdx = $assoc['peerBlockIdx'] ?? null;
221
+ if ($peerSeed !== null && $peerBlockIdx !== null) {
222
+ $combined .= self::generateBlock($peerSeed, (int)$peerBlockIdx);
223
+ }
224
+ $store->delete("coop-assoc:{$nonce}");
225
+ }
226
+
81
227
  $finalBlock = $combined . $nonce . ":" . $clientSecret;
82
228
  $hash = hash('sha256', $finalBlock);
83
229
  return hash_equals($hash, $solution);
84
230
  }
85
231
 
232
+ public static function verifyZkpProof(string $yStr, string $tStr, string $sStr): bool
233
+ {
234
+ try {
235
+ $p = BigInt::fromHex('fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f'); // secp256k1 prime
236
+ $g = new BigInt(2);
237
+
238
+ $y = BigInt::fromHex($yStr);
239
+ $t = BigInt::fromHex($tStr);
240
+ $s = BigInt::fromHex($sStr);
241
+
242
+ $cStr = (string)$g . (string)$y . (string)$t;
243
+ $cHex = hash('sha256', $cStr);
244
+ $c = BigInt::fromHex($cHex)->mod($p);
245
+
246
+ $left = $g->modPow($s, $p);
247
+ $y_c = $y->modPow($c, $p);
248
+ $right = $t->mul($y_c)->mod($p);
249
+
250
+ return $left->compareTo($right) === 0;
251
+ } catch (\Throwable $e) {
252
+ return false;
253
+ }
254
+ }
255
+
86
256
  /**
87
257
  * Récupère la clé secrète pour les PoW depuis les variables d'environnement.
88
258
  */
@@ -102,6 +272,22 @@ class ChallengeUtils
102
272
  */
103
273
  public static function generateStatelessTicket(array $payload): string
104
274
  {
275
+ $ed25519Key = $_ENV['ED25519_PRIVATE_KEY'] ?? getenv('ED25519_PRIVATE_KEY');
276
+ if ($ed25519Key) {
277
+ try {
278
+ $serialized = json_encode($payload);
279
+ $privateKey = openssl_pkey_get_private($ed25519Key);
280
+ if ($privateKey && openssl_sign($serialized, $signature, $privateKey, null)) {
281
+ $base64UrlEncode = function ($input) {
282
+ return rtrim(strtr(base64_encode($input), '+/', '-_'), '=');
283
+ };
284
+ return 'ed25519.' . $base64UrlEncode($serialized) . '.' . $base64UrlEncode($signature);
285
+ }
286
+ } catch (\Throwable $e) {
287
+ error_log("[ChallengeUtils] Ed25519 signing failed: " . $e->getMessage());
288
+ }
289
+ }
290
+
105
291
  $key = hash('sha256', self::getPowSecret(), true);
106
292
  $iv = random_bytes(16);
107
293
  $encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
@@ -115,10 +301,40 @@ class ChallengeUtils
115
301
  /**
116
302
  * Décode et valide un ticket stateless chiffré et signé.
117
303
  * @param string $ticket
304
+ * @param string $secret
118
305
  * @return array|null
119
306
  */
120
- public static function parseStatelessTicket(string $ticket): ?array
307
+ public static function parseStatelessTicket(string $ticket, string $secret = ''): ?array
121
308
  {
309
+ try {
310
+ if (str_starts_with($ticket, 'ed25519.')) {
311
+ $parts = explode('.', $ticket);
312
+ if (count($parts) !== 3) {
313
+ return null;
314
+ }
315
+ $base64UrlDecode = function ($input) {
316
+ return base64_decode(strtr($input, '-_', '+/'));
317
+ };
318
+ $payloadJson = $base64UrlDecode($parts[1]);
319
+ $signature = $base64UrlDecode($parts[2]);
320
+
321
+ $ed25519PubKey = $_ENV['ED25519_PUBLIC_KEY'] ?? getenv('ED25519_PUBLIC_KEY');
322
+ if (!$ed25519PubKey) {
323
+ error_log("[ChallengeUtils] ED25519_PUBLIC_KEY is not defined in environment.");
324
+ return null;
325
+ }
326
+
327
+ $publicKey = openssl_pkey_get_public($ed25519PubKey);
328
+ if ($publicKey && openssl_verify($payloadJson, $signature, $publicKey, null) === 1) {
329
+ return json_decode($payloadJson, true);
330
+ }
331
+ return null;
332
+ }
333
+ } catch (\Throwable $e) {
334
+ error_log("[ChallengeUtils] Ed25519 verification failed: " . $e->getMessage());
335
+ return null;
336
+ }
337
+
122
338
  $parts = explode('.', $ticket);
123
339
  if (count($parts) !== 3) {
124
340
  return null;
@@ -132,7 +348,7 @@ class ChallengeUtils
132
348
  if (!$iv || !$encrypted || !$signature || strlen($iv) !== 16) {
133
349
  return null;
134
350
  }
135
- $key = hash('sha256', self::getPowSecret(), true);
351
+ $key = hash('sha256', !empty($secret) ? $secret : self::getPowSecret(), true);
136
352
  $expectedSignature = hash_hmac('sha256', $iv . $encrypted, $key, true);
137
353
  if (!hash_equals($expectedSignature, $signature)) {
138
354
  return null;
@@ -143,20 +359,23 @@ class ChallengeUtils
143
359
 
144
360
  /**
145
361
  * Vérifie si un ticket de passage est valide (supporte les tickets opaques via store et le fallback legacy).
362
+ * Supporte une clé secrète optionnelle passée en paramètre pour la compatibilité avec les tests.
146
363
  */
147
364
  public static function isTicketValid(
148
365
  ?string $ip,
149
366
  ?string $ticket,
150
367
  string $deviceId = '',
151
368
  string $deviceHash = '',
152
- bool $allowCrossNetworkRoaming = false
369
+ bool $allowCrossNetworkRoaming = false,
370
+ string $secret = '',
371
+ string $zkpProof = ''
153
372
  ): bool {
154
373
  if (empty($ip) || empty($ticket)) {
155
374
  return false;
156
375
  }
157
376
 
158
377
  // Tentative de validation stateless d'abord
159
- $ticketData = self::parseStatelessTicket($ticket);
378
+ $ticketData = self::parseStatelessTicket($ticket, $secret);
160
379
  if ($ticketData !== null) {
161
380
  $expiry = $ticketData['expiry'] ?? null;
162
381
  $originalIp = $ticketData['originalIp'] ?? null;
@@ -166,6 +385,18 @@ class ChallengeUtils
166
385
  if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
167
386
  return false;
168
387
  }
388
+ if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
389
+ $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
390
+ if (!empty($zkpProof)) {
391
+ $zkpParts = explode(':', $zkpProof);
392
+ if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
393
+ if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
394
+ return true;
395
+ }
396
+ }
397
+ }
398
+ return false;
399
+ }
169
400
  if ($ip === $originalIp) {
170
401
  return true;
171
402
  }
@@ -193,6 +424,18 @@ class ChallengeUtils
193
424
  $store->delete("ticket:{$ticket}");
194
425
  return false;
195
426
  }
427
+ if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
428
+ $expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
429
+ if (!empty($zkpProof)) {
430
+ $zkpParts = explode(':', $zkpProof);
431
+ if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
432
+ if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
433
+ return true;
434
+ }
435
+ }
436
+ }
437
+ return false;
438
+ }
196
439
 
197
440
  if ($ip === $originalIp) {
198
441
  return true;
@@ -221,7 +464,7 @@ class ChallengeUtils
221
464
  return false;
222
465
  }
223
466
 
224
- $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", self::getPowSecret());
467
+ $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", !empty($secret) ? $secret : self::getPowSecret());
225
468
 
226
469
  return hash_equals($expectedSig, $sig);
227
470
  }
@@ -467,6 +710,9 @@ class ChallengeUtils
467
710
  $sizeMb = $challengeDetails['sizeMb'];
468
711
  $queries = $challengeDetails['queries'];
469
712
  $path = $challengeDetails['path'];
713
+ $peerId = $challengeDetails['peerId'] ?? '';
714
+ $peerBlockIdx = $challengeDetails['peerBlockIdx'] ?? -1;
715
+ $nodeId = $nonce;
470
716
 
471
717
  $solverCode = self::getPowSolverCode();
472
718
  $queriesJson = json_encode($queries);
@@ -478,16 +724,63 @@ class ChallengeUtils
478
724
  const clientSecret = "{$clientSecret}";
479
725
  const queries = {$queriesJson};
480
726
  const sizeMb = {$sizeMb};
727
+ const nodeId = "{$nodeId}";
728
+ const peerId = "{$peerId}";
729
+ const peerBlockIdx = {$peerBlockIdx};
481
730
 
482
731
  document.getElementById('loader').innerText = '⚙️ Checking persistent local storage...';
483
732
  await new Promise(r => setTimeout(r, 10));
484
733
 
485
734
  try {
486
735
  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
736
 
490
- window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash;
737
+ // Enregistrement coopératif
738
+ await fetch(window.location.pathname + "?coop_op=register&node_id=" + nodeId + "&seed=" + encodeURIComponent(nonce + ":" + clientSecret));
739
+
740
+ // Écoute des requêtes entrantes de nos pairs suspects
741
+ setInterval(async () => {
742
+ try {
743
+ const res = await fetch(window.location.pathname + "?coop_op=poll_requests&node_id=" + nodeId);
744
+ const data = await res.json();
745
+ if (data.requests && data.requests.length > 0) {
746
+ for (const req of data.requests) {
747
+ document.getElementById('loader').innerText = '📤 Transfert coopératif de bloc vers le pair...';
748
+ const blockData = await window.readSpaceBlock(req.block_idx);
749
+ await fetch(window.location.pathname + "?coop_op=respond_block&node_id=" + nodeId + "&requester_id=" + req.requester_id + "&req_id=" + req.req_id + "&block_data=" + encodeURIComponent(blockData));
750
+ }
751
+ }
752
+ } catch (e) {
753
+ console.error("Cooperative polling error", e);
754
+ }
755
+ }, 1000);
756
+
757
+ // Téléchargement du bloc du pair si configuré
758
+ let peerBlock = "";
759
+ if (peerId && peerBlockIdx !== -1) {
760
+ document.getElementById('loader').innerText = '📥 Téléchargement du bloc de validation du pair (' + peerId + ')...';
761
+ const reqId = Math.random().toString(36).substring(2);
762
+ await fetch(window.location.pathname + "?coop_op=request_peer_block&node_id=" + nodeId + "&peer_id=" + peerId + "&block_idx=" + peerBlockIdx + "&req_id=" + reqId);
763
+
764
+ let attempts = 0;
765
+ while (attempts < 15) {
766
+ const res = await fetch(window.location.pathname + "?coop_op=poll_response&node_id=" + nodeId + "&req_id=" + reqId);
767
+ const data = await res.json();
768
+ if (data.status === 'ready') {
769
+ peerBlock = data.block_data;
770
+ break;
771
+ }
772
+ await new Promise(r => setTimeout(r, 1000));
773
+ attempts++;
774
+ }
775
+ if (!peerBlock) {
776
+ document.getElementById('loader').innerText = '⚠️ Peer de sous-réseau injoignable. Validation solo...';
777
+ }
778
+ }
779
+
780
+ document.getElementById('loader').innerText = '⚙️ Génération de la Preuve d\\'Espace...';
781
+ const hash = await window.solveSpaceChallenge(nonce + ":" + clientSecret, queries, nonce, clientSecret, peerBlock);
782
+
783
+ window.location.href = path + "?pow_type=pospace&pow_nonce=" + nonce + "&pow_solution_space=" + hash + (peerBlock ? "&pow_coop=1" : "");
491
784
  } catch(e) {
492
785
  document.getElementById('loader').innerText = "Error initializing local storage: " + e.message;
493
786
  }