@anonympins/fingerprint 0.4.5 → 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.
@@ -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,235 @@ 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 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
+
168
+ public static function generateSpaceChallenge(string $clientIp, string $nonce, float $suspicionFactor, string $originalUrl, array $securityConfig): array
169
+ {
170
+ $pospaceConfig = $securityConfig['pospace'] ?? [];
171
+ $sizeMb = $pospaceConfig['sizeMb'] ?? 100;
172
+ $numQueries = $pospaceConfig['numQueries'] ?? 10;
173
+
174
+ $queries = [];
175
+ $maxBlocks = $sizeMb * 1024;
176
+ while (count($queries) < $numQueries) {
177
+ $idx = random_int(0, $maxBlocks - 1);
178
+ if (!in_array($idx, $queries, true)) {
179
+ $queries[] = $idx;
180
+ }
181
+ }
182
+
183
+ $challenge = [
184
+ 'type' => 'pospace',
185
+ 'nonce' => $nonce,
186
+ 'sizeMb' => $sizeMb,
187
+ 'queries' => $queries,
188
+ 'path' => $originalUrl
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;
206
+ }
207
+
208
+ public static function verifySpacePoW(string $nonce, string $solution, array $queries, string $seed, string $clientSecret): bool
209
+ {
210
+ $combined = '';
211
+ foreach ($queries as $idx) {
212
+ $combined .= self::generateBlock($seed, (int)$idx);
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
+
227
+ $finalBlock = $combined . $nonce . ":" . $clientSecret;
228
+ $hash = hash('sha256', $finalBlock);
229
+ return hash_equals($hash, $solution);
230
+ }
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
+
26
256
  /**
27
257
  * Récupère la clé secrète pour les PoW depuis les variables d'environnement.
28
258
  */
@@ -42,6 +272,22 @@ class ChallengeUtils
42
272
  */
43
273
  public static function generateStatelessTicket(array $payload): string
44
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
+
45
291
  $key = hash('sha256', self::getPowSecret(), true);
46
292
  $iv = random_bytes(16);
47
293
  $encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
@@ -55,10 +301,40 @@ class ChallengeUtils
55
301
  /**
56
302
  * Décode et valide un ticket stateless chiffré et signé.
57
303
  * @param string $ticket
304
+ * @param string $secret
58
305
  * @return array|null
59
306
  */
60
- public static function parseStatelessTicket(string $ticket): ?array
307
+ public static function parseStatelessTicket(string $ticket, string $secret = ''): ?array
61
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
+
62
338
  $parts = explode('.', $ticket);
63
339
  if (count($parts) !== 3) {
64
340
  return null;
@@ -72,7 +348,7 @@ class ChallengeUtils
72
348
  if (!$iv || !$encrypted || !$signature || strlen($iv) !== 16) {
73
349
  return null;
74
350
  }
75
- $key = hash('sha256', self::getPowSecret(), true);
351
+ $key = hash('sha256', !empty($secret) ? $secret : self::getPowSecret(), true);
76
352
  $expectedSignature = hash_hmac('sha256', $iv . $encrypted, $key, true);
77
353
  if (!hash_equals($expectedSignature, $signature)) {
78
354
  return null;
@@ -83,20 +359,23 @@ class ChallengeUtils
83
359
 
84
360
  /**
85
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.
86
363
  */
87
364
  public static function isTicketValid(
88
365
  ?string $ip,
89
366
  ?string $ticket,
90
367
  string $deviceId = '',
91
368
  string $deviceHash = '',
92
- bool $allowCrossNetworkRoaming = false
369
+ bool $allowCrossNetworkRoaming = false,
370
+ string $secret = '',
371
+ string $zkpProof = ''
93
372
  ): bool {
94
373
  if (empty($ip) || empty($ticket)) {
95
374
  return false;
96
375
  }
97
376
 
98
377
  // Tentative de validation stateless d'abord
99
- $ticketData = self::parseStatelessTicket($ticket);
378
+ $ticketData = self::parseStatelessTicket($ticket, $secret);
100
379
  if ($ticketData !== null) {
101
380
  $expiry = $ticketData['expiry'] ?? null;
102
381
  $originalIp = $ticketData['originalIp'] ?? null;
@@ -106,6 +385,18 @@ class ChallengeUtils
106
385
  if (!$expiry || (int)floor(microtime(true) * 1000) > (int)$expiry) {
107
386
  return false;
108
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
+ }
109
400
  if ($ip === $originalIp) {
110
401
  return true;
111
402
  }
@@ -133,6 +424,18 @@ class ChallengeUtils
133
424
  $store->delete("ticket:{$ticket}");
134
425
  return false;
135
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
+ }
136
439
 
137
440
  if ($ip === $originalIp) {
138
441
  return true;
@@ -161,7 +464,7 @@ class ChallengeUtils
161
464
  return false;
162
465
  }
163
466
 
164
- $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", self::getPowSecret());
467
+ $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", !empty($secret) ? $secret : self::getPowSecret());
165
468
 
166
469
  return hash_equals($expectedSig, $sig);
167
470
  }
@@ -401,6 +704,93 @@ class ChallengeUtils
401
704
  return file_get_contents($solverPath) ?: '';
402
705
  }
403
706
 
707
+ public static function generateSpaceChallengePage(array $challengeDetails, string $clientSecret, array $securityConfig): string
708
+ {
709
+ $nonce = $challengeDetails['nonce'];
710
+ $sizeMb = $challengeDetails['sizeMb'];
711
+ $queries = $challengeDetails['queries'];
712
+ $path = $challengeDetails['path'];
713
+ $peerId = $challengeDetails['peerId'] ?? '';
714
+ $peerBlockIdx = $challengeDetails['peerBlockIdx'] ?? -1;
715
+ $nodeId = $nonce;
716
+
717
+ $solverCode = self::getPowSolverCode();
718
+ $queriesJson = json_encode($queries);
719
+
720
+ $challengeScript = <<<JS
721
+ async function solve() {
722
+ const nonce = "{$nonce}";
723
+ const path = "{$path}";
724
+ const clientSecret = "{$clientSecret}";
725
+ const queries = {$queriesJson};
726
+ const sizeMb = {$sizeMb};
727
+ const nodeId = "{$nodeId}";
728
+ const peerId = "{$peerId}";
729
+ const peerBlockIdx = {$peerBlockIdx};
730
+
731
+ document.getElementById('loader').innerText = '⚙️ Checking persistent local storage...';
732
+ await new Promise(r => setTimeout(r, 10));
733
+
734
+ try {
735
+ await window.initializeSpace(nonce + ":" + clientSecret, sizeMb);
736
+
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" : "");
784
+ } catch(e) {
785
+ document.getElementById('loader').innerText = "Error initializing local storage: " + e.message;
786
+ }
787
+ }
788
+ solve();
789
+ JS;
790
+
791
+ 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>";
792
+ }
793
+
404
794
  /**
405
795
  * Génère le contenu HTML pour un challenge combiné CPU + Mémoire.
406
796
  * @param array $cpuChallengeDetails