@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.
- package/CHANGELOG.md +21 -0
- package/README.md +56 -11
- package/package.json +2 -1
- package/src/js/fingerprint.client.js +139 -0
- package/src/js/fingerprint.js +360 -191
- package/src/js/fingerprint.utils.js +184 -0
- package/src/js/library.js +1 -1
- package/src/js/pow.solver.inline.js +12 -4
- package/src/js/pow.solver.js +12 -4
- package/src/js/tests/fingerprint.isMalicious.test.js +234 -116
- package/src/js/tests/fingerprint.test.js +132 -1
- package/src/js/tests/ja3AnomalyDetector.test.js +1 -0
- package/src/php/AutoTuner.php +71 -0
- package/src/php/Challenge/ChallengeUtils.php +302 -9
- package/src/php/FingerprintEngine.php +40 -2
- package/src/php/Tests/ChallengeUtilsTest.php +208 -81
- package/src/php/Tests/MaliciousPatternsTest.php +104 -0
- package/src/php/Utils/BigInt.php +202 -144
- package/src/php/Utils/MaliciousPatterns.php +74 -58
|
@@ -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
|
|
package/src/php/AutoTuner.php
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|