@anonympins/fingerprint 0.7.5 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/public/anonympins-bot-mitigation-pow.zip +0 -0
- package/src/js/asn-lookup.js +223 -0
- package/src/js/fingerprint.client.js +1784 -1772
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +236 -40
- package/src/js/fingerprint.utils.js +252 -2
- package/src/js/pow.worker.js +2 -2
- package/src/js/tests/asn-lookup.test.js +57 -0
- package/src/js/tests/cross_parity.test.js +3 -3
- package/src/js/tests/dns-circuit-breaker.test.js +6 -6
- package/src/js/tests/fingerprint.builder.test.js +76 -78
- package/src/js/tests/fingerprint.client.test.js +16 -17
- package/src/js/tests/fingerprint.test.js +19 -12
- package/src/js/tests/ja3AnomalyDetector.test.js +135 -136
- package/src/js/tests/library.test.js +94 -95
- package/src/js/tests/obfuscation.test.js +8 -8
- package/src/js/tests/quicFingerprint.test.js +47 -0
- package/src/js/tests/tcpFingerprint.test.js +4 -4
- package/src/js/upow-model-task.js +155 -112
- package/src/php/AsnLookupEngine.php +173 -0
- package/src/php/Challenge/ChallengeUtils.php +430 -117
- package/src/php/Config/SecurityProfiles.php +75 -62
- package/src/php/DirectFingerprint.php +80 -13
- package/src/php/FingerprintBuilder.php +13 -13
- package/src/php/FingerprintClient.php +6 -6
- package/src/php/FingerprintEngine.php +2141 -1837
- package/src/php/Ja3AnomalyDetector.php +22 -22
- package/src/php/NetworkProfile.php +68 -0
- package/src/php/Optimization/FunctionRegistry.php +4 -4
- package/src/php/Optimization/Optimization.php +6 -6
- package/src/php/ProblemManager.php +12 -2
- package/src/php/RequestContext.php +11 -11
- package/src/php/Store/InMemoryStore.php +96 -66
- package/src/php/Store/MongoDbStore.php +2 -2
- package/src/php/Store/RedisStore.php +1 -1
- package/src/php/Store/StoreManager.php +48 -35
- package/src/php/Tests/AsnLookupTest.php +61 -0
- package/src/php/Tests/ChallengeUtilsTest.php +31 -0
- package/src/php/Tests/DnsCircuitBreakerTest.php +105 -105
- package/src/php/Tests/FingerprintClientTest.php +70 -70
- package/src/php/Tests/FingerprintEngineTest.php +4 -2
- package/src/php/Tests/PowTest.php +37 -39
- package/src/php/Tests/QuicFingerprintTest.php +115 -53
- package/src/php/Tests/ThreatIntelTest.php +2 -2
- package/src/php/Utils/BigInt.php +202 -202
- package/src/php/Utils/BlockList.php +13 -13
- package/src/php/Utils/Env.php +7 -2
- package/src/php/Utils/Logger.php +19 -1
- package/src/php/Utils/MaliciousPatterns.php +6 -6
- package/src/php/Utils/MetricsManager.php +209 -209
- package/src/php/Utils/RequestUtils.php +348 -18
- package/src/php/Utils/TLSClientHelloParser.php +385 -369
- package/src/php/WordPress/WpDbStore.php +34 -7
- package/src/php/WordPress/anonympins-bot-mitigation-pow.php +1222 -0
- package/src/php/WordPress/fingerprint-anti-bot.php +0 -559
- package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.mo +0 -0
- package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +232 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +297 -261
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.mo +0 -0
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +348 -267
- package/src/php/WordPress/package.php +88 -99
- package/src/php/WordPress/readme.txt +28 -6
- package/src/php/bin/auto-tune.php +1 -1
- package/public/fingerprint-anti-bot.zip +0 -0
|
@@ -8,22 +8,36 @@ use Anonympins\Fingerprint\Store\StoreManager;
|
|
|
8
8
|
use Anonympins\Fingerprint\FingerprintBuilder;
|
|
9
9
|
use Anonympins\Fingerprint\Utils\BigInt;
|
|
10
10
|
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
11
|
-
|
|
11
|
+
use Anonympins\Fingerprint\Utils\Env;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Utility class for generating and verifying Proof-of-Work challenges.
|
|
15
15
|
*/
|
|
16
16
|
class ChallengeUtils
|
|
17
17
|
{
|
|
18
|
-
/** @var array<string, float>
|
|
18
|
+
/** @var array<string, float> Local cache of converted floats to avoid repeated pack/unpack system calls */
|
|
19
19
|
private static array $froundCache = [];
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Emulates JavaScript's Math.fround: rounds a float to the nearest 32-bit
|
|
23
|
+
* single-precision float. Uses a local cache to avoid repeated pack/unpack calls.
|
|
24
|
+
*
|
|
25
|
+
* @param float $value The float value to round.
|
|
26
|
+
* @return float The value rounded to single precision.
|
|
27
|
+
*/
|
|
21
28
|
private static function fround(float $value): float
|
|
22
29
|
{
|
|
23
30
|
$key = (string)$value;
|
|
24
31
|
return self::$froundCache[$key] ?? (self::$froundCache[$key] = unpack('f', pack('f', $value))[1]);
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Hashes a string seed into a normalized float between 0 and 1
|
|
36
|
+
* using a simple 32-bit rolling hash (similar to Java's String.hashCode).
|
|
37
|
+
*
|
|
38
|
+
* @param string $seed The seed string to hash.
|
|
39
|
+
* @return float A pseudo-random float in the range [0, 1).
|
|
40
|
+
*/
|
|
27
41
|
public static function hashSeedToFloat(string $seed): float
|
|
28
42
|
{
|
|
29
43
|
$hash = 0;
|
|
@@ -36,6 +50,15 @@ class ChallengeUtils
|
|
|
36
50
|
return abs($hash % 1000000) / 1000000;
|
|
37
51
|
}
|
|
38
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Derives a set of 4 unique sample indices from the client IP and a secret,
|
|
55
|
+
* using an HMAC-SHA256 over the client IP and a 5-minute time window.
|
|
56
|
+
* This makes the indices deterministic but rotating over time.
|
|
57
|
+
*
|
|
58
|
+
* @param string $clientIp The client IP address.
|
|
59
|
+
* @param string $secret The shared secret used for the HMAC.
|
|
60
|
+
* @return array<int> An array of 4 unique indices in the range [0, 63].
|
|
61
|
+
*/
|
|
39
62
|
public static function deriveSampleIndices(string $clientIp, string $secret): array
|
|
40
63
|
{
|
|
41
64
|
$timeWindow = (int)floor(time() / (60 * 5));
|
|
@@ -56,6 +79,19 @@ class ChallengeUtils
|
|
|
56
79
|
return $indices;
|
|
57
80
|
}
|
|
58
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Verifies a GPU Proof-of-Work solution based on the logistic map
|
|
84
|
+
* (chaotic iteration x_{n+1} = r * x_n * (1 - x_n) with r = 3.9999).
|
|
85
|
+
* Only a subset of the 64 values is recomputed, using indices derived
|
|
86
|
+
* from the client IP and a shared secret.
|
|
87
|
+
*
|
|
88
|
+
* @param string $seed The challenge seed.
|
|
89
|
+
* @param int $iterations The number of logistic map iterations per value.
|
|
90
|
+
* @param string $solution A comma-separated string of 64 float values.
|
|
91
|
+
* @param string $clientIp The client IP used to derive the sample indices.
|
|
92
|
+
* @param string $secret The secret used to derive the sample indices.
|
|
93
|
+
* @return bool True if the sampled values match, false otherwise.
|
|
94
|
+
*/
|
|
59
95
|
public static function verifyGpuPow(string $seed, int $iterations, string $solution, string $clientIp = '127.0.0.1', string $secret = 'gpu-pow-salt'): bool
|
|
60
96
|
{
|
|
61
97
|
$values = explode(',', $solution);
|
|
@@ -82,6 +118,7 @@ class ChallengeUtils
|
|
|
82
118
|
return true;
|
|
83
119
|
}
|
|
84
120
|
|
|
121
|
+
/** Templates used to generate signed trap URLs that lure malicious crawlers. */
|
|
85
122
|
private const TRAP_URL_TEMPLATES = [
|
|
86
123
|
'/includes/config-{RANDOM}.php',
|
|
87
124
|
'/.env.{RANDOM}',
|
|
@@ -92,6 +129,14 @@ class ChallengeUtils
|
|
|
92
129
|
'/.git/config_{RANDOM}'
|
|
93
130
|
];
|
|
94
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Emulates a 32-bit signed integer multiplication (like Math.imul in JS),
|
|
134
|
+
* handling overflow correctly.
|
|
135
|
+
*
|
|
136
|
+
* @param int $a The first operand.
|
|
137
|
+
* @param int $b The second operand.
|
|
138
|
+
* @return int The 32-bit signed result of a * b.
|
|
139
|
+
*/
|
|
95
140
|
private static function imul(int $a, int $b): int
|
|
96
141
|
{
|
|
97
142
|
$ah = ($a >> 16) & 0xffff;
|
|
@@ -103,11 +148,21 @@ class ChallengeUtils
|
|
|
103
148
|
return (($hi << 16) | ($lo & 0xffff)) | 0;
|
|
104
149
|
}
|
|
105
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Deterministically generates a 1024-byte block of pseudo-random data
|
|
153
|
+
* from a seed and a block index, using a rolling hash (cyrb53-based).
|
|
154
|
+
* This is the building block for the proof-of-space challenge.
|
|
155
|
+
*
|
|
156
|
+
* @param string $seed The seed string.
|
|
157
|
+
* @param int $blockIndex The index of the block to generate.
|
|
158
|
+
* @param int $blockSize The size of the block in bytes (default 1024).
|
|
159
|
+
* @return string The raw binary block content.
|
|
160
|
+
*/
|
|
106
161
|
private static function generateBlock(string $seed, int $blockIndex, int $blockSize = 1024): string
|
|
107
162
|
{
|
|
108
163
|
$block = str_repeat("\x00", $blockSize);
|
|
109
164
|
$h = FingerprintBuilder::cyrb53($seed . ":" . $blockIndex);
|
|
110
|
-
|
|
165
|
+
|
|
111
166
|
$h_int = (int)bcmod($h, '4294967296');
|
|
112
167
|
for ($i = 0; $i < $blockSize; $i++) {
|
|
113
168
|
$h_int = self::imul($h_int ^ $i, 1597334677);
|
|
@@ -116,6 +171,15 @@ class ChallengeUtils
|
|
|
116
171
|
return $block;
|
|
117
172
|
}
|
|
118
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Registers a cooperative node in the store, keyed by its IP subnet.
|
|
176
|
+
* Entries older than 2 minutes are pruned before adding the new node.
|
|
177
|
+
*
|
|
178
|
+
* @param string $clientIp The client IP (used to compute the subnet).
|
|
179
|
+
* @param string $nodeId The unique node identifier.
|
|
180
|
+
* @param string $seed The node's seed.
|
|
181
|
+
* @return void
|
|
182
|
+
*/
|
|
119
183
|
public static function registerCooperativeNode(string $clientIp, string $nodeId, string $seed): void
|
|
120
184
|
{
|
|
121
185
|
$subnet = RequestUtils::getIpSubnet($clientIp);
|
|
@@ -125,20 +189,28 @@ class ChallengeUtils
|
|
|
125
189
|
$store = StoreManager::getStore();
|
|
126
190
|
$key = "coop-pospace:subnet:{$subnet}";
|
|
127
191
|
$nodes = $store->get($key) ?? [];
|
|
128
|
-
|
|
192
|
+
|
|
129
193
|
$now = time();
|
|
130
|
-
//
|
|
194
|
+
// Clean up expired nodes (older than 2 minutes)
|
|
131
195
|
$nodes = array_filter($nodes, fn($n) => ($now - $n['timestamp']) < 120);
|
|
132
|
-
|
|
196
|
+
|
|
133
197
|
$nodes[$nodeId] = [
|
|
134
198
|
'nodeId' => $nodeId,
|
|
135
199
|
'seed' => $seed,
|
|
136
200
|
'timestamp' => $now
|
|
137
201
|
];
|
|
138
|
-
|
|
202
|
+
|
|
139
203
|
$store->set($key, $nodes, 120);
|
|
140
204
|
}
|
|
141
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Finds a random active peer node in the same subnet as the client,
|
|
208
|
+
* excluding a given node ID.
|
|
209
|
+
*
|
|
210
|
+
* @param string $clientIp The client IP (used to compute the subnet).
|
|
211
|
+
* @param string $excludeNodeId The node ID to exclude from the results.
|
|
212
|
+
* @return array|null The peer node data, or null if none is found.
|
|
213
|
+
*/
|
|
142
214
|
public static function findPeerInSubnet(string $clientIp, string $excludeNodeId): ?array
|
|
143
215
|
{
|
|
144
216
|
$subnet = RequestUtils::getIpSubnet($clientIp);
|
|
@@ -148,7 +220,7 @@ class ChallengeUtils
|
|
|
148
220
|
$store = StoreManager::getStore();
|
|
149
221
|
$key = "coop-pospace:subnet:{$subnet}";
|
|
150
222
|
$nodes = $store->get($key) ?? [];
|
|
151
|
-
|
|
223
|
+
|
|
152
224
|
$now = time();
|
|
153
225
|
$activePeers = [];
|
|
154
226
|
foreach ($nodes as $id => $node) {
|
|
@@ -156,14 +228,25 @@ class ChallengeUtils
|
|
|
156
228
|
$activePeers[] = $node;
|
|
157
229
|
}
|
|
158
230
|
}
|
|
159
|
-
|
|
231
|
+
|
|
160
232
|
if (empty($activePeers)) {
|
|
161
233
|
return null;
|
|
162
234
|
}
|
|
163
|
-
|
|
235
|
+
|
|
164
236
|
return $activePeers[array_rand($activePeers)];
|
|
165
237
|
}
|
|
166
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Handles cooperative peer-to-peer operations (federation threat intel sharing,
|
|
241
|
+
* node registration, peer discovery, WebRTC signaling, block requests/responses).
|
|
242
|
+
* Enforces cooperative signature verification for every operation except
|
|
243
|
+
* the federation threat intel sharing path.
|
|
244
|
+
*
|
|
245
|
+
* @param array $params The request parameters (includes `coop_op`).
|
|
246
|
+
* @param string $clientIp The client IP address.
|
|
247
|
+
* @param array $config Additional configuration (federated peers, thresholds, keys).
|
|
248
|
+
* @return array|null The response payload, or null if `coop_op` is missing.
|
|
249
|
+
*/
|
|
167
250
|
public static function handleCooperativeRequest(array $params, string $clientIp = '127.0.0.1', array $config = []): ?array
|
|
168
251
|
{
|
|
169
252
|
$op = $params['coop_op'] ?? null;
|
|
@@ -252,7 +335,7 @@ class ChallengeUtils
|
|
|
252
335
|
return ['error' => 'Missing node_id'];
|
|
253
336
|
}
|
|
254
337
|
|
|
255
|
-
// ---
|
|
338
|
+
// --- COOPERATIVE SIGNATURE VERIFICATION ---
|
|
256
339
|
$challengeContext = $store->get("secret:{$nodeId}");
|
|
257
340
|
if (!$challengeContext || empty($challengeContext['clientSecret'])) {
|
|
258
341
|
return ['error' => 'Invalid or expired node_id'];
|
|
@@ -270,19 +353,19 @@ class ChallengeUtils
|
|
|
270
353
|
$expectedMsg = "{$clientSecret}:find_peer:{$nodeId}";
|
|
271
354
|
break;
|
|
272
355
|
case 'webrtc_signal':
|
|
273
|
-
$expectedMsg = "{$clientSecret}:webrtc_signal:{$nodeId}:" . ($params['target_peer_id']
|
|
356
|
+
$expectedMsg = "{$clientSecret}:webrtc_signal:{$nodeId}:" . (isset($params['target_peer_id']) ? self::sanitizeString($params['target_peer_id']) : '') . ":" . (isset($params['signal_type']) ? self::sanitizeString($params['signal_type']) : '') . ":" . (isset($params['signal_data']) ? self::sanitizeString($params['signal_data']) : '');
|
|
274
357
|
break;
|
|
275
358
|
case 'poll_signals':
|
|
276
359
|
$expectedMsg = "{$clientSecret}:poll_signals:{$nodeId}";
|
|
277
360
|
break;
|
|
278
361
|
case 'request_peer_block':
|
|
279
|
-
$expectedMsg = "{$clientSecret}:request_peer_block:{$nodeId}:" . ($params['peer_id']
|
|
362
|
+
$expectedMsg = "{$clientSecret}:request_peer_block:{$nodeId}:" . (isset($params['peer_id']) ? self::sanitizeString($params['peer_id']) : '') . ":" . ($params['block_idx'] ?? '0') . ":" . (isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '');
|
|
280
363
|
break;
|
|
281
364
|
case 'poll_requests':
|
|
282
365
|
$expectedMsg = "{$clientSecret}:poll_requests:{$nodeId}";
|
|
283
366
|
break;
|
|
284
367
|
case 'respond_block':
|
|
285
|
-
$expectedMsg = "{$clientSecret}:respond_block:{$nodeId}:" . ($params['requester_id']
|
|
368
|
+
$expectedMsg = "{$clientSecret}:respond_block:{$nodeId}:" . (isset($params['requester_id']) ? self::sanitizeString($params['requester_id']) : '') . ":" . (isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '') . ":" . (isset($params['block_data']) ? self::sanitizeString($params['block_data']) : '');
|
|
286
369
|
break;
|
|
287
370
|
case 'poll_response':
|
|
288
371
|
$expectedMsg = "{$clientSecret}:poll_response:{$nodeId}:" . ($params['req_id'] ?? '');
|
|
@@ -295,12 +378,12 @@ class ChallengeUtils
|
|
|
295
378
|
if (!hash_equals($expectedSig, $coopSig)) {
|
|
296
379
|
return ['error' => 'Invalid cooperative signature'];
|
|
297
380
|
}
|
|
298
|
-
// ---
|
|
381
|
+
// --- END OF VERIFICATION ---
|
|
299
382
|
|
|
300
383
|
switch ($op) {
|
|
301
384
|
case 'register':
|
|
302
385
|
$clientIp = self::getSanitizedServerVar('REMOTE_ADDR', '127.0.0.1');
|
|
303
|
-
$seed = $params['seed']
|
|
386
|
+
$seed = isset($params['seed']) ? self::sanitizeString($params['seed']) : '';
|
|
304
387
|
self::registerCooperativeNode($clientIp, $nodeId, $seed);
|
|
305
388
|
return ['status' => 'registered'];
|
|
306
389
|
|
|
@@ -312,9 +395,9 @@ class ChallengeUtils
|
|
|
312
395
|
return ['status' => 'no_peers'];
|
|
313
396
|
|
|
314
397
|
case 'webrtc_signal':
|
|
315
|
-
$targetPeerId = $params['target_peer_id']
|
|
316
|
-
$signalType = $params['signal_type']
|
|
317
|
-
$signalData = $params['signal_data']
|
|
398
|
+
$targetPeerId = isset($params['target_peer_id']) ? self::sanitizeString($params['target_peer_id']) : '';
|
|
399
|
+
$signalType = isset($params['signal_type']) ? self::sanitizeString($params['signal_type']) : '';
|
|
400
|
+
$signalData = isset($params['signal_data']) ? self::sanitizeString($params['signal_data']) : '';
|
|
318
401
|
if (empty($targetPeerId) || empty($signalType) || empty($signalData)) {
|
|
319
402
|
return ['error' => 'Invalid parameters'];
|
|
320
403
|
}
|
|
@@ -337,13 +420,13 @@ class ChallengeUtils
|
|
|
337
420
|
return ['status' => 'ok', 'signals' => $signals];
|
|
338
421
|
|
|
339
422
|
case 'request_peer_block':
|
|
340
|
-
$peerId = $params['peer_id']
|
|
423
|
+
$peerId = isset($params['peer_id']) ? self::sanitizeString($params['peer_id']) : '';
|
|
341
424
|
$blockIdx = (int)($params['block_idx'] ?? 0);
|
|
342
|
-
$requestId = $params['req_id']
|
|
425
|
+
$requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
|
|
343
426
|
if (empty($peerId) || empty($requestId)) {
|
|
344
427
|
return ['error' => 'Invalid parameters'];
|
|
345
428
|
}
|
|
346
|
-
|
|
429
|
+
|
|
347
430
|
$queueKey = "coop-mailbox:queue:{$peerId}";
|
|
348
431
|
$requests = $store->get($queueKey) ?? [];
|
|
349
432
|
$requests[] = [
|
|
@@ -361,19 +444,19 @@ class ChallengeUtils
|
|
|
361
444
|
return ['requests' => $requests];
|
|
362
445
|
|
|
363
446
|
case 'respond_block':
|
|
364
|
-
$requesterId = $params['requester_id']
|
|
365
|
-
$requestId = $params['req_id']
|
|
366
|
-
$blockData = $params['block_data']
|
|
447
|
+
$requesterId = isset($params['requester_id']) ? self::sanitizeString($params['requester_id']) : '';
|
|
448
|
+
$requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
|
|
449
|
+
$blockData = isset($params['block_data']) ? self::sanitizeString($params['block_data']) : '';
|
|
367
450
|
if (empty($requesterId) || empty($requestId)) {
|
|
368
451
|
return ['error' => 'Invalid parameters'];
|
|
369
452
|
}
|
|
370
|
-
|
|
453
|
+
|
|
371
454
|
$responseKey = "coop-mailbox:res:{$requesterId}:{$requestId}";
|
|
372
455
|
$store->set($responseKey, ['block_data' => $blockData], 30);
|
|
373
456
|
return ['status' => 'delivered'];
|
|
374
457
|
|
|
375
458
|
case 'poll_response':
|
|
376
|
-
$requestId = $params['req_id']
|
|
459
|
+
$requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
|
|
377
460
|
$responseKey = "coop-mailbox:res:{$nodeId}:{$requestId}";
|
|
378
461
|
$data = $store->get($responseKey);
|
|
379
462
|
if ($data) {
|
|
@@ -385,6 +468,18 @@ class ChallengeUtils
|
|
|
385
468
|
return null;
|
|
386
469
|
}
|
|
387
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Generates a proof-of-space challenge for a client, including random block
|
|
473
|
+
* queries. If a peer is found in the same subnet, a cooperative component is
|
|
474
|
+
* added to the challenge (peer ID and a random block index).
|
|
475
|
+
*
|
|
476
|
+
* @param string $clientIp The client IP address.
|
|
477
|
+
* @param string $nonce The challenge nonce.
|
|
478
|
+
* @param float $suspicionFactor The suspicion factor for the request.
|
|
479
|
+
* @param string $originalUrl The original requested URL (used as redirect path).
|
|
480
|
+
* @param array $securityConfig The security configuration array.
|
|
481
|
+
* @return array The challenge details.
|
|
482
|
+
*/
|
|
388
483
|
public static function generateSpaceChallenge(string $clientIp, string $nonce, float $suspicionFactor, string $originalUrl, array $securityConfig): array
|
|
389
484
|
{
|
|
390
485
|
$pospaceConfig = $securityConfig['pospace'] ?? [];
|
|
@@ -408,12 +503,12 @@ class ChallengeUtils
|
|
|
408
503
|
'path' => $originalUrl
|
|
409
504
|
];
|
|
410
505
|
|
|
411
|
-
//
|
|
506
|
+
// Attempt cooperative coupling with a peer node in the same subnet
|
|
412
507
|
$peer = self::findPeerInSubnet($clientIp, $nonce);
|
|
413
508
|
if ($peer !== null) {
|
|
414
509
|
$challenge['peerId'] = $peer['nodeId'];
|
|
415
510
|
$challenge['peerBlockIdx'] = random_int(0, $maxBlocks - 1);
|
|
416
|
-
|
|
511
|
+
|
|
417
512
|
$store = StoreManager::getStore();
|
|
418
513
|
$store->set("coop-assoc:{$nonce}", [
|
|
419
514
|
'peerNodeId' => $peer['nodeId'],
|
|
@@ -425,14 +520,26 @@ class ChallengeUtils
|
|
|
425
520
|
return $challenge;
|
|
426
521
|
}
|
|
427
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Verifies a proof-of-space solution by reconstructing the expected hash
|
|
525
|
+
* from the queried blocks. If a cooperative association exists for the nonce,
|
|
526
|
+
* the peer block is also appended to the combined data.
|
|
527
|
+
*
|
|
528
|
+
* @param string $nonce The challenge nonce.
|
|
529
|
+
* @param string $solution The client-provided solution hash.
|
|
530
|
+
* @param array $queries The list of queried block indices.
|
|
531
|
+
* @param string $seed The seed used to generate the blocks.
|
|
532
|
+
* @param string $clientSecret The client secret.
|
|
533
|
+
* @return bool True if the solution matches, false otherwise.
|
|
534
|
+
*/
|
|
428
535
|
public static function verifySpacePoW(string $nonce, string $solution, array $queries, string $seed, string $clientSecret): bool
|
|
429
536
|
{
|
|
430
537
|
$combined = '';
|
|
431
538
|
foreach ($queries as $idx) {
|
|
432
539
|
$combined .= self::generateBlock($seed, (int)$idx);
|
|
433
540
|
}
|
|
434
|
-
|
|
435
|
-
//
|
|
541
|
+
|
|
542
|
+
// Cooperative proof verification
|
|
436
543
|
$store = StoreManager::getStore();
|
|
437
544
|
$assoc = $store->get("coop-assoc:{$nonce}");
|
|
438
545
|
if ($assoc !== null) {
|
|
@@ -449,6 +556,15 @@ class ChallengeUtils
|
|
|
449
556
|
return hash_equals($hash, $solution);
|
|
450
557
|
}
|
|
451
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Verifies a Schnorr-style Zero-Knowledge Proof using the secp256k1 prime.
|
|
561
|
+
* Checks that g^s ≡ t * y^c (mod p), where c = SHA-256(g, y, t) mod p.
|
|
562
|
+
*
|
|
563
|
+
* @param string $yStr The public key y (hex).
|
|
564
|
+
* @param string $tStr The commitment t (hex).
|
|
565
|
+
* @param string $sStr The response s (hex).
|
|
566
|
+
* @return bool True if the proof is valid, false otherwise.
|
|
567
|
+
*/
|
|
452
568
|
public static function verifyZkpProof(string $yStr, string $tStr, string $sStr): bool
|
|
453
569
|
{
|
|
454
570
|
try {
|
|
@@ -474,7 +590,11 @@ class ChallengeUtils
|
|
|
474
590
|
}
|
|
475
591
|
|
|
476
592
|
/**
|
|
477
|
-
*
|
|
593
|
+
* Retrieves the secret key for PoW tasks from environment variables.
|
|
594
|
+
* Throws an exception in production if the secret is missing.
|
|
595
|
+
*
|
|
596
|
+
* @return string The PoW secret.
|
|
597
|
+
* @throws \RuntimeException If missing in production.
|
|
478
598
|
*/
|
|
479
599
|
public static function getPowSecret(): string
|
|
480
600
|
{
|
|
@@ -487,9 +607,70 @@ class ChallengeUtils
|
|
|
487
607
|
}
|
|
488
608
|
|
|
489
609
|
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
610
|
+
* Programmatically generates an Ed25519 key pair in PEM format
|
|
611
|
+
* matching OpenSSL CLI:
|
|
612
|
+
* `openssl genpkey -algorithm ed25519 -out issuer-private.pem`
|
|
613
|
+
* `openssl pkey -in issuer-private.pem -pubout -out issuer-public.pem`
|
|
614
|
+
*
|
|
615
|
+
* @param string $outDir Target directory to store the PEM files.
|
|
616
|
+
* @param array $options Options including custom file names.
|
|
617
|
+
* @return array{privateKeyPath: string, publicKeyPath: string, privateKey: string, publicKey: string}
|
|
618
|
+
* @throws \RuntimeException If OpenSSL or Ed25519 is not supported or key generation fails.
|
|
619
|
+
*/
|
|
620
|
+
public static function generateIssuerPemKeys(string $outDir = '', array $options = []): array
|
|
621
|
+
{
|
|
622
|
+
if (!defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
623
|
+
throw new \RuntimeException("Ed25519 is not supported in this OpenSSL environment.");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
$pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
|
|
627
|
+
if (!$pkey || !@openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
628
|
+
throw new \RuntimeException("Failed to generate Ed25519 private key.");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
$details = openssl_pkey_get_details($pkey);
|
|
632
|
+
$publicKeyPem = $details['key'] ?? '';
|
|
633
|
+
if (empty($publicKeyPem)) {
|
|
634
|
+
throw new \RuntimeException("Failed to extract Ed25519 public key.");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
$privName = $options['privateKeyName'] ?? 'issuer-private.pem';
|
|
638
|
+
$pubName = $options['publicKeyName'] ?? 'issuer-public.pem';
|
|
639
|
+
|
|
640
|
+
$privateKeyPath = '';
|
|
641
|
+
$publicKeyPath = '';
|
|
642
|
+
|
|
643
|
+
if ($outDir !== '') {
|
|
644
|
+
if (!is_dir($outDir)) {
|
|
645
|
+
if (function_exists('wp_mkdir_p')) {
|
|
646
|
+
wp_mkdir_p($outDir);
|
|
647
|
+
} else {
|
|
648
|
+
mkdir($outDir, 0755, true);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
$privateKeyPath = rtrim($outDir, '/\\') . '/' . $privName;
|
|
652
|
+
$publicKeyPath = rtrim($outDir, '/\\') . '/' . $pubName;
|
|
653
|
+
file_put_contents($privateKeyPath, $privateKeyPem);
|
|
654
|
+
@chmod($privateKeyPath, 0600);
|
|
655
|
+
file_put_contents($publicKeyPath, $publicKeyPem);
|
|
656
|
+
@chmod($publicKeyPath, 0644);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return [
|
|
660
|
+
'privateKeyPath' => $privateKeyPath,
|
|
661
|
+
'publicKeyPath' => $publicKeyPath,
|
|
662
|
+
'privateKey' => $privateKeyPem,
|
|
663
|
+
'publicKey' => $publicKeyPem,
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Generates an encrypted and signed stateless ticket containing the
|
|
669
|
+
* authorization context. Uses Ed25519 if a private key is available,
|
|
670
|
+
* otherwise falls back to AES-256-CBC + HMAC-SHA256.
|
|
671
|
+
*
|
|
672
|
+
* @param array $payload The authorization context to embed.
|
|
673
|
+
* @return string The encoded ticket.
|
|
493
674
|
*/
|
|
494
675
|
public static function generateStatelessTicket(array $payload): string
|
|
495
676
|
{
|
|
@@ -513,17 +694,19 @@ class ChallengeUtils
|
|
|
513
694
|
$iv = random_bytes(16);
|
|
514
695
|
$encrypted = openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
|
515
696
|
$signature = hash_hmac('sha256', $iv . $encrypted, $key, true);
|
|
516
|
-
|
|
697
|
+
|
|
517
698
|
return rtrim(strtr(base64_encode($iv), '+/', '-_'), '=') . '.' .
|
|
518
|
-
|
|
519
|
-
|
|
699
|
+
rtrim(strtr(base64_encode($encrypted), '+/', '-_'), '=') . '.' .
|
|
700
|
+
rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
|
520
701
|
}
|
|
521
702
|
|
|
522
703
|
/**
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* @
|
|
704
|
+
* Decodes and validates an encrypted and signed stateless ticket.
|
|
705
|
+
* Supports both Ed25519 and AES-256-CBC + HMAC-SHA256 formats.
|
|
706
|
+
*
|
|
707
|
+
* @param string $ticket The ticket to decode.
|
|
708
|
+
* @param string $secret Optional secret for HMAC verification.
|
|
709
|
+
* @return array|null The decoded payload, or null if invalid.
|
|
527
710
|
*/
|
|
528
711
|
public static function parseStatelessTicket(string $ticket, string $secret = ''): ?array
|
|
529
712
|
{
|
|
@@ -538,13 +721,13 @@ class ChallengeUtils
|
|
|
538
721
|
};
|
|
539
722
|
$payloadJson = $base64UrlDecode($parts[1]);
|
|
540
723
|
$signature = $base64UrlDecode($parts[2]);
|
|
541
|
-
|
|
724
|
+
|
|
542
725
|
$ed25519PubKey = Env::get('ED25519_PUBLIC_KEY');
|
|
543
726
|
if (!$ed25519PubKey) {
|
|
544
727
|
self::logError("[ChallengeUtils] ED25519_PUBLIC_KEY is not defined in environment.");
|
|
545
728
|
return null;
|
|
546
729
|
}
|
|
547
|
-
|
|
730
|
+
|
|
548
731
|
$publicKey = openssl_pkey_get_public($ed25519PubKey);
|
|
549
732
|
if ($publicKey && openssl_verify($payloadJson, $signature, $publicKey, null) === 1) {
|
|
550
733
|
return json_decode($payloadJson, true);
|
|
@@ -579,8 +762,18 @@ class ChallengeUtils
|
|
|
579
762
|
}
|
|
580
763
|
|
|
581
764
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
765
|
+
* Checks whether a pass ticket is valid. Supports opaque tickets (via the store)
|
|
766
|
+
* and the legacy stateless fallback. Handles greenlist, ZKP, same-IP, same-subnet,
|
|
767
|
+
* and cross-network roaming validations.
|
|
768
|
+
*
|
|
769
|
+
* @param string|null $ip The client IP.
|
|
770
|
+
* @param string|null $ticket The ticket to validate.
|
|
771
|
+
* @param string $deviceId The client device ID.
|
|
772
|
+
* @param string $deviceHash The client device hash.
|
|
773
|
+
* @param bool $allowCrossNetworkRoaming Whether to allow roaming across networks.
|
|
774
|
+
* @param string $secret Optional secret for legacy ticket validation.
|
|
775
|
+
* @param string $zkpProof Optional ZKP proof in the format "y:t:s".
|
|
776
|
+
* @return bool True if the ticket is valid, false otherwise.
|
|
584
777
|
*/
|
|
585
778
|
public static function isTicketValid(
|
|
586
779
|
?string $ip,
|
|
@@ -595,7 +788,7 @@ class ChallengeUtils
|
|
|
595
788
|
return false;
|
|
596
789
|
}
|
|
597
790
|
|
|
598
|
-
//
|
|
791
|
+
// Try stateless validation first
|
|
599
792
|
$ticketData = self::parseStatelessTicket($ticket, $secret);
|
|
600
793
|
if ($ticketData !== null) {
|
|
601
794
|
$expiry = $ticketData['expiry'] ?? null;
|
|
@@ -611,18 +804,18 @@ class ChallengeUtils
|
|
|
611
804
|
return true;
|
|
612
805
|
}
|
|
613
806
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
807
|
+
if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
|
|
808
|
+
$expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
|
|
809
|
+
if (!empty($zkpProof)) {
|
|
810
|
+
$zkpParts = explode(':', $zkpProof);
|
|
811
|
+
if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
|
|
812
|
+
if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
|
|
813
|
+
return true;
|
|
622
814
|
}
|
|
623
815
|
}
|
|
624
|
-
return false;
|
|
625
816
|
}
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
626
819
|
if ($ip === $originalIp) {
|
|
627
820
|
return true;
|
|
628
821
|
}
|
|
@@ -655,18 +848,18 @@ class ChallengeUtils
|
|
|
655
848
|
return true;
|
|
656
849
|
}
|
|
657
850
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
851
|
+
if ($storedDeviceHash && str_starts_with($storedDeviceHash, 'zkp:')) {
|
|
852
|
+
$expectedY = explode(':', $storedDeviceHash, 2)[1] ?? '';
|
|
853
|
+
if (!empty($zkpProof)) {
|
|
854
|
+
$zkpParts = explode(':', $zkpProof);
|
|
855
|
+
if (count($zkpParts) === 3 && $zkpParts[0] === $expectedY) {
|
|
856
|
+
if (self::verifyZkpProof($zkpParts[0], $zkpParts[1], $zkpParts[2])) {
|
|
857
|
+
return true;
|
|
666
858
|
}
|
|
667
859
|
}
|
|
668
|
-
return false;
|
|
669
860
|
}
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
670
863
|
|
|
671
864
|
if ($ip === $originalIp) {
|
|
672
865
|
return true;
|
|
@@ -685,7 +878,7 @@ class ChallengeUtils
|
|
|
685
878
|
return !empty($deviceId) && $deviceId === $storedDeviceId && !empty($deviceHash) && $deviceHash === $storedDeviceHash;
|
|
686
879
|
}
|
|
687
880
|
|
|
688
|
-
//
|
|
881
|
+
// Backward-compatible fallback for legacy signed tickets (stateless)
|
|
689
882
|
if (!str_contains($ticket, ':')) {
|
|
690
883
|
return false;
|
|
691
884
|
}
|
|
@@ -701,7 +894,12 @@ class ChallengeUtils
|
|
|
701
894
|
}
|
|
702
895
|
|
|
703
896
|
/**
|
|
704
|
-
*
|
|
897
|
+
* Computes the difficulty target for a CPU challenge based on the
|
|
898
|
+
* suspicion factor. Higher suspicion yields a harder (smaller) target.
|
|
899
|
+
*
|
|
900
|
+
* @param float $suspicionFactor The suspicion factor (0.0 to 1.0).
|
|
901
|
+
* @param array $securityConfig The security configuration.
|
|
902
|
+
* @return string The target as a hex string.
|
|
705
903
|
*/
|
|
706
904
|
public static function calculateCpuTarget(float $suspicionFactor, array $securityConfig): string
|
|
707
905
|
{
|
|
@@ -712,7 +910,7 @@ class ChallengeUtils
|
|
|
712
910
|
$totalDifficultyBits = $minDifficultyBits + $suspicionFactor * ($maxDifficultyBits - $minDifficultyBits);
|
|
713
911
|
|
|
714
912
|
if ($totalDifficultyBits <= 0) {
|
|
715
|
-
//
|
|
913
|
+
// Maximum target (trivial challenge)
|
|
716
914
|
return (BigInt::pow(2, 256)->sub(new BigInt(1)))->toHex();
|
|
717
915
|
}
|
|
718
916
|
|
|
@@ -721,7 +919,15 @@ class ChallengeUtils
|
|
|
721
919
|
}
|
|
722
920
|
|
|
723
921
|
/**
|
|
724
|
-
*
|
|
922
|
+
* Creates the base data block used by the CPU challenge. The fingerprint
|
|
923
|
+
* parts are sorted to ensure deterministic ordering.
|
|
924
|
+
*
|
|
925
|
+
* @param string $nonce The challenge nonce.
|
|
926
|
+
* @param string $clientSecret The client secret.
|
|
927
|
+
* @param string $fingerprint The client fingerprint (pipe-separated).
|
|
928
|
+
* @param string $clientIp The client IP.
|
|
929
|
+
* @param string $tlsSessionId The TLS session ID.
|
|
930
|
+
* @return string The concatenated base block.
|
|
725
931
|
*/
|
|
726
932
|
public static function createCpuChallengeBaseBlock(string $nonce, string $clientSecret, string $fingerprint, string $clientIp = '', string $tlsSessionId = ''): string
|
|
727
933
|
{
|
|
@@ -729,13 +935,23 @@ class ChallengeUtils
|
|
|
729
935
|
$filteredParts = array_filter($parts);
|
|
730
936
|
sort($filteredParts);
|
|
731
937
|
$sortedFingerprint = implode('|', $filteredParts);
|
|
732
|
-
|
|
938
|
+
|
|
733
939
|
return "{$nonce}:{$clientSecret}:{$sortedFingerprint}:{$clientIp}:{$tlsSessionId}:";
|
|
734
940
|
}
|
|
735
941
|
|
|
736
942
|
/**
|
|
737
|
-
*
|
|
738
|
-
*
|
|
943
|
+
* Verifies a CPU Proof-of-Work solution and, on success, generates a
|
|
944
|
+
* stateless ticket. In HTTP (insecure) mode, no SHA-256 computation is
|
|
945
|
+
* required and a ticket is issued directly.
|
|
946
|
+
*
|
|
947
|
+
* @param string $clientIp The client IP.
|
|
948
|
+
* @param int $ticketTtl The ticket time-to-live in milliseconds.
|
|
949
|
+
* @param string $nonce The challenge nonce.
|
|
950
|
+
* @param string $solution The client-provided solution.
|
|
951
|
+
* @param array $challengeContext The challenge context (cpuTarget, baseBlock, isHttp).
|
|
952
|
+
* @param string $deviceId The client device ID.
|
|
953
|
+
* @param string $deviceHash The client device hash.
|
|
954
|
+
* @return string|null The opaque ticket on success, or null on failure.
|
|
739
955
|
*/
|
|
740
956
|
public static function verifyCpuTargetPoWAndGenerateTicket(
|
|
741
957
|
string $clientIp,
|
|
@@ -746,9 +962,9 @@ class ChallengeUtils
|
|
|
746
962
|
string $deviceId = '',
|
|
747
963
|
string $deviceHash = ''
|
|
748
964
|
): ?string {
|
|
749
|
-
//
|
|
965
|
+
// In HTTP (insecure) mode, no SHA-256 computation is required
|
|
750
966
|
if (!empty($challengeContext['isHttp'])) {
|
|
751
|
-
self::logError('[FP Server Verify]
|
|
967
|
+
self::logError('[FP Server Verify] HTTP mode detected (insecure policy): validation without SHA-256 accepted.');
|
|
752
968
|
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
753
969
|
return self::generateStatelessTicket([
|
|
754
970
|
'expiry' => $expiry,
|
|
@@ -775,7 +991,7 @@ class ChallengeUtils
|
|
|
775
991
|
|
|
776
992
|
if ($isValid) {
|
|
777
993
|
self::logError('[FP Server Verify] CPU PoW verification PASSED.');
|
|
778
|
-
|
|
994
|
+
|
|
779
995
|
$expiry = (int)floor(microtime(true) * 1000) + $ticketTtl;
|
|
780
996
|
$payload = [
|
|
781
997
|
'expiry' => $expiry,
|
|
@@ -797,7 +1013,13 @@ class ChallengeUtils
|
|
|
797
1013
|
}
|
|
798
1014
|
|
|
799
1015
|
/**
|
|
800
|
-
*
|
|
1016
|
+
* Checks the Token Bucket rate limiter for challenge requests from a subnet.
|
|
1017
|
+
* Tokens are refilled at a constant rate up to a maximum capacity.
|
|
1018
|
+
*
|
|
1019
|
+
* @param string $clientIp The client IP (subnet is used as the rate-limit key).
|
|
1020
|
+
* @param float $capacity The bucket capacity (max tokens).
|
|
1021
|
+
* @param float $refillRate The token refill rate (tokens per second).
|
|
1022
|
+
* @return bool True if the request is allowed, false if rate-limited.
|
|
801
1023
|
*/
|
|
802
1024
|
public static function checkChallengeRateLimit(string $clientIp, float $capacity = 5.0, float $refillRate = 0.1): bool
|
|
803
1025
|
{
|
|
@@ -836,7 +1058,14 @@ class ChallengeUtils
|
|
|
836
1058
|
}
|
|
837
1059
|
|
|
838
1060
|
/**
|
|
839
|
-
*
|
|
1061
|
+
* Derives the set of challenged block indices for a memory PoW solution,
|
|
1062
|
+
* based on the seed, the solution, and the number of blocks.
|
|
1063
|
+
*
|
|
1064
|
+
* @param string $seed The challenge seed.
|
|
1065
|
+
* @param int $solution The client solution (used as a salt in the hash).
|
|
1066
|
+
* @param int $numBlocks The total number of blocks.
|
|
1067
|
+
* @param int $k The number of challenged indices to derive (default 4).
|
|
1068
|
+
* @return array<int> The list of challenged block indices.
|
|
840
1069
|
*/
|
|
841
1070
|
private static function getChallengedIndices(string $seed, int $solution, int $numBlocks, int $k = 4): array
|
|
842
1071
|
{
|
|
@@ -849,6 +1078,16 @@ class ChallengeUtils
|
|
|
849
1078
|
return $indices;
|
|
850
1079
|
}
|
|
851
1080
|
|
|
1081
|
+
/**
|
|
1082
|
+
* Verifies a Merkle proof for a given leaf hash, index, and expected root.
|
|
1083
|
+
* Recomputes the root by hashing pairs of siblings along the path.
|
|
1084
|
+
*
|
|
1085
|
+
* @param string $leafHash The hex hash of the leaf.
|
|
1086
|
+
* @param int $index The leaf index in the tree.
|
|
1087
|
+
* @param array $proof The list of sibling hashes (hex).
|
|
1088
|
+
* @param string $root The expected Merkle root (hex).
|
|
1089
|
+
* @return bool True if the proof is valid, false otherwise.
|
|
1090
|
+
*/
|
|
852
1091
|
private static function verifyMerkleProof(string $leafHash, int $index, array $proof, string $root): bool
|
|
853
1092
|
{
|
|
854
1093
|
$currentHash = $leafHash;
|
|
@@ -861,6 +1100,17 @@ class ChallengeUtils
|
|
|
861
1100
|
return $currentHash === $root;
|
|
862
1101
|
}
|
|
863
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* Legacy memory PoW verification, used for low-difficulty challenges with
|
|
1105
|
+
* a simple numeric solution. Rebuilds the whole buffer and replays the
|
|
1106
|
+
* random walk to compare with the provided solution.
|
|
1107
|
+
*
|
|
1108
|
+
* @param string $nonce The challenge nonce.
|
|
1109
|
+
* @param int $solution The client-provided solution.
|
|
1110
|
+
* @param int $difficulty The difficulty (in MB).
|
|
1111
|
+
* @param string $clientSecret The client secret.
|
|
1112
|
+
* @return bool True if the solution matches, false otherwise.
|
|
1113
|
+
*/
|
|
864
1114
|
private static function verifyMemoryPoWLegacy(string $nonce, int $solution, int $difficulty, string $clientSecret): bool
|
|
865
1115
|
{
|
|
866
1116
|
$size = $difficulty * 1024 * 1024;
|
|
@@ -887,6 +1137,17 @@ class ChallengeUtils
|
|
|
887
1137
|
return $finalHash === $solution;
|
|
888
1138
|
}
|
|
889
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* Verifies a memory PoW solution. Supports both the modern JSON format
|
|
1142
|
+
* (with Merkle proofs) and the legacy numeric format for low difficulties.
|
|
1143
|
+
* Enforces a maximum allowed difficulty to prevent DoS.
|
|
1144
|
+
*
|
|
1145
|
+
* @param string $nonce The challenge nonce.
|
|
1146
|
+
* @param string $solution The client-provided solution (JSON or numeric string).
|
|
1147
|
+
* @param int $difficulty The difficulty (in MB). 0 means the challenge is skipped.
|
|
1148
|
+
* @param string $clientSecret The client secret.
|
|
1149
|
+
* @return bool True if the solution is valid, false otherwise.
|
|
1150
|
+
*/
|
|
890
1151
|
public static function verifyMemoryPoW(
|
|
891
1152
|
string $nonce,
|
|
892
1153
|
string $solution,
|
|
@@ -972,7 +1233,12 @@ class ChallengeUtils
|
|
|
972
1233
|
}
|
|
973
1234
|
|
|
974
1235
|
/**
|
|
975
|
-
*
|
|
1236
|
+
* Emulates JavaScript's 32-bit signed integer multiplication (Math.imul),
|
|
1237
|
+
* handling overflow correctly.
|
|
1238
|
+
*
|
|
1239
|
+
* @param int $a The first operand.
|
|
1240
|
+
* @param int $b The second operand.
|
|
1241
|
+
* @return int The 32-bit signed result of a * b.
|
|
976
1242
|
*/
|
|
977
1243
|
private static function gmp_imul(int $a, int $b): int
|
|
978
1244
|
{
|
|
@@ -984,9 +1250,11 @@ class ChallengeUtils
|
|
|
984
1250
|
}
|
|
985
1251
|
|
|
986
1252
|
/**
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
1253
|
+
* Generates a signed trap URL. The URL is picked from a template and a
|
|
1254
|
+
* signature is appended as a query parameter.
|
|
1255
|
+
*
|
|
1256
|
+
* @param string $nonce The nonce used to sign the URL.
|
|
1257
|
+
* @return string The trap URL.
|
|
990
1258
|
*/
|
|
991
1259
|
public static function generateTrapUrl(string $nonce): string
|
|
992
1260
|
{
|
|
@@ -999,11 +1267,13 @@ class ChallengeUtils
|
|
|
999
1267
|
}
|
|
1000
1268
|
|
|
1001
1269
|
/**
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
* @param string $
|
|
1006
|
-
* @
|
|
1270
|
+
* Verifies whether a given path and signature form a valid trap URL for
|
|
1271
|
+
* the given nonce. Uses hash_equals for timing-safe comparison.
|
|
1272
|
+
*
|
|
1273
|
+
* @param string $path The request path.
|
|
1274
|
+
* @param string $signature The signature from the query string.
|
|
1275
|
+
* @param string $nonce The nonce to verify against.
|
|
1276
|
+
* @return bool True if the trap URL is valid, false otherwise.
|
|
1007
1277
|
*/
|
|
1008
1278
|
public static function verifyTrapUrl(string $path, string $signature, string $nonce): bool
|
|
1009
1279
|
{
|
|
@@ -1011,39 +1281,56 @@ class ChallengeUtils
|
|
|
1011
1281
|
return false;
|
|
1012
1282
|
}
|
|
1013
1283
|
$expectedSignature = substr(hash_hmac('sha256', $nonce . $path, self::getPowSecret()), 0, 16);
|
|
1014
|
-
//
|
|
1284
|
+
// Use hash_equals for timing-safe comparison.
|
|
1015
1285
|
return hash_equals($expectedSignature, $signature);
|
|
1016
1286
|
}
|
|
1017
1287
|
|
|
1018
1288
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1289
|
+
* Loads the JavaScript solver code for inline injection into challenge pages.
|
|
1290
|
+
* Searches several candidate paths, including WordPress plugin directories.
|
|
1291
|
+
*
|
|
1292
|
+
* @return string The JavaScript solver code, or an empty string if not found.
|
|
1021
1293
|
*/
|
|
1022
1294
|
private static function getPowSolverCode(): string
|
|
1023
1295
|
{
|
|
1296
|
+
if (defined('ANONYMPINS_BOT_MITIGATION_DIR')) {
|
|
1297
|
+
$wpPluginSolver = rtrim(ANONYMPINS_BOT_MITIGATION_DIR, '/\\') . '/assets/pow.solver.inline.js';
|
|
1298
|
+
if (file_exists($wpPluginSolver)) {
|
|
1299
|
+
return (string)file_get_contents($wpPluginSolver);
|
|
1300
|
+
}
|
|
1301
|
+
} elseif (defined('WP_PLUGIN_DIR')) {
|
|
1302
|
+
$wpPluginSolver = WP_PLUGIN_DIR . '/anonympins-bot-mitigation-pow/assets/pow.solver.inline.js';
|
|
1303
|
+
if (file_exists($wpPluginSolver)) {
|
|
1304
|
+
return (string)file_get_contents($wpPluginSolver);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1024
1308
|
$candidates = [
|
|
1025
1309
|
__DIR__ . '/../../js/pow.solver.inline.js',
|
|
1026
1310
|
__DIR__ . '/../assets/pow.solver.inline.js',
|
|
1027
1311
|
__DIR__ . '/../js/pow.solver.inline.js',
|
|
1028
|
-
dirname(__DIR__
|
|
1029
|
-
dirname(__DIR__
|
|
1030
|
-
dirname(__DIR__, 1) . '/assets/pow.solver.inline.js',
|
|
1312
|
+
dirname(__DIR__) . '/assets/pow.solver.inline.js',
|
|
1313
|
+
dirname(__DIR__) . '/js/pow.solver.inline.js',
|
|
1031
1314
|
];
|
|
1032
|
-
if (defined('ABSPATH')) {
|
|
1033
|
-
$wpPluginSolver = ABSPATH . 'wp-content/plugins/fingerprint-anti-bot/assets/pow.solver.inline.js';
|
|
1034
|
-
if (file_exists($wpPluginSolver)) {
|
|
1035
|
-
return file_get_contents($wpPluginSolver) ?: '';
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
1315
|
foreach ($candidates as $solverPath) {
|
|
1039
1316
|
if (file_exists($solverPath)) {
|
|
1040
1317
|
return file_get_contents($solverPath) ?: '';
|
|
1041
1318
|
}
|
|
1042
1319
|
}
|
|
1043
|
-
self::logError("[ChallengeUtils]
|
|
1320
|
+
self::logError("[ChallengeUtils] Error: The pow.solver.inline.js file was not found at the expected location.");
|
|
1044
1321
|
return '';
|
|
1045
1322
|
}
|
|
1046
1323
|
|
|
1324
|
+
/**
|
|
1325
|
+
* Generates the HTML page for a proof-of-space challenge, embedding the
|
|
1326
|
+
* solver code and the challenge script. The page initializes local storage,
|
|
1327
|
+
* runs the proof-of-space solver, and redirects with the solution.
|
|
1328
|
+
*
|
|
1329
|
+
* @param array $challengeDetails The challenge details (nonce, sizeMb, queries, path, peerId, peerBlockIdx).
|
|
1330
|
+
* @param string $clientSecret The client secret.
|
|
1331
|
+
* @param array $securityConfig The security configuration.
|
|
1332
|
+
* @return string The generated HTML page.
|
|
1333
|
+
*/
|
|
1047
1334
|
public static function generateSpaceChallengePage(array $challengeDetails, string $clientSecret, array $securityConfig): string
|
|
1048
1335
|
{
|
|
1049
1336
|
$nonce = $challengeDetails['nonce'];
|
|
@@ -1089,14 +1376,21 @@ class ChallengeUtils
|
|
|
1089
1376
|
}
|
|
1090
1377
|
|
|
1091
1378
|
/**
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
* @param array $
|
|
1097
|
-
* @param
|
|
1098
|
-
* @param string $
|
|
1099
|
-
* @
|
|
1379
|
+
* Generates the HTML page for a combined CPU + Memory proof-of-work challenge.
|
|
1380
|
+
* The page embeds the solver code, the challenge script, and hidden trap links
|
|
1381
|
+
* (rendered in random tags/positions) to lure malicious crawlers.
|
|
1382
|
+
*
|
|
1383
|
+
* @param array $cpuChallengeDetails The CPU challenge details (nonce, target, path).
|
|
1384
|
+
* @param int $memoryDifficulty The memory difficulty in MB (0 to skip).
|
|
1385
|
+
* @param string $clientSecret The client secret.
|
|
1386
|
+
* @param array $securityConfig The security configuration.
|
|
1387
|
+
* @param array $trapUrls The list of trap URLs to embed.
|
|
1388
|
+
* @param string $originalFingerprint The original client fingerprint.
|
|
1389
|
+
* @param string $clientIp The client IP.
|
|
1390
|
+
* @param string $tlsSessionId The TLS session ID.
|
|
1391
|
+
* @param string|null $baseBlock Optional pre-computed base block.
|
|
1392
|
+
* @param bool $isHttps Whether the request is over HTTPS.
|
|
1393
|
+
* @return string The generated HTML page.
|
|
1100
1394
|
*/
|
|
1101
1395
|
public static function generateCombinedPoWChallengePage(
|
|
1102
1396
|
array $cpuChallengeDetails,
|
|
@@ -1191,12 +1485,39 @@ class ChallengeUtils
|
|
|
1191
1485
|
);
|
|
1192
1486
|
}
|
|
1193
1487
|
|
|
1488
|
+
/**
|
|
1489
|
+
* Logs an error message using the PHP error log.
|
|
1490
|
+
*
|
|
1491
|
+
* @param string $message The message to log.
|
|
1492
|
+
* @return void
|
|
1493
|
+
*/
|
|
1194
1494
|
private static function logError(string $message): void
|
|
1195
1495
|
{
|
|
1196
1496
|
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Challenge diagnostic logging
|
|
1197
1497
|
error_log($message);
|
|
1198
1498
|
}
|
|
1199
1499
|
|
|
1500
|
+
/**
|
|
1501
|
+
* Safely sanitizes a string input using WordPress functions if available, or fallback PHP functions.
|
|
1502
|
+
*
|
|
1503
|
+
* @param mixed $value
|
|
1504
|
+
* @return string
|
|
1505
|
+
*/
|
|
1506
|
+
private static function sanitizeString($value): string
|
|
1507
|
+
{
|
|
1508
|
+
if (!is_scalar($value)) {
|
|
1509
|
+
return '';
|
|
1510
|
+
}
|
|
1511
|
+
$val = (string)$value;
|
|
1512
|
+
if (function_exists('wp_unslash')) {
|
|
1513
|
+
$val = wp_unslash($val);
|
|
1514
|
+
}
|
|
1515
|
+
if (function_exists('sanitize_text_field')) {
|
|
1516
|
+
return sanitize_text_field($val);
|
|
1517
|
+
}
|
|
1518
|
+
return trim(strip_tags($val));
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1200
1521
|
/**
|
|
1201
1522
|
* Safely retrieves and sanitizes a value from the $_SERVER superglobal.
|
|
1202
1523
|
* Uses WordPress functions if available, otherwise falls back to basic PHP sanitization.
|
|
@@ -1213,14 +1534,6 @@ class ChallengeUtils
|
|
|
1213
1534
|
|
|
1214
1535
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed and sanitized immediately below
|
|
1215
1536
|
$value = $_SERVER[$key];
|
|
1216
|
-
|
|
1217
|
-
if (function_exists('wp_unslash')) {
|
|
1218
|
-
$value = wp_unslash($value);
|
|
1219
|
-
}
|
|
1220
|
-
if (function_exists('sanitize_text_field')) {
|
|
1221
|
-
return sanitize_text_field($value);
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
return is_scalar($value) ? (string) $value : '';
|
|
1537
|
+
return self::sanitizeString($value);
|
|
1225
1538
|
}
|
|
1226
1539
|
}
|