@anonympins/fingerprint 0.8.0 → 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.
@@ -5,6 +5,7 @@ import {Worker} from "node:worker_threads";
5
5
  import {getProblemManager, problemManager} from "./problem-manager.js";
6
6
  import {Optimization} from "./library.js";
7
7
  import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
8
+ import {defaultAsnLookup, NetworkProfile} from "./asn-lookup.js";
8
9
  import {DynamicWasmGenerator} from "./dynamic-wasm.js";
9
10
  import {writeFileSync, readFileSync, existsSync, promises as fsPromises} from "node:fs";
10
11
  import {fileURLToPath} from "node:url";
@@ -3438,18 +3439,26 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
3438
3439
  /**
3439
3440
  * Calcule un score d'incohérence analogique lisse plafonnant à une asymptote de 99.9.
3440
3441
  * @param {number} consistencyScore Similarité entre 0 et 1 issue du FingerprintBuilder.
3441
- * @param {number} [inflectionPoint=0.72] Point d'inflexion où la suspicion accélère.
3442
+ * @param {number|object} [inflectionOrProfile=0.72] Point d'inflexion ou profil réseau/IP.
3442
3443
  * @param {number} [steepness=12] Raideur de la transition sigmoïdale.
3443
3444
  * @returns {number} Score de 0 à 99.9 sans saut de palier ni certitude absolue à 100.
3444
3445
  */
3445
- export function calculateAnalogInconsistencyScore(consistencyScore, inflectionPoint = 0.72, steepness = 12) {
3446
+ export function calculateAnalogInconsistencyScore(consistencyScore, inflectionOrProfile = 0.72, steepness = 12) {
3446
3447
  const s = Math.max(0.0, Math.min(1.0, consistencyScore));
3447
3448
  if (s >= 0.98) return 0.0;
3448
3449
 
3450
+ let inflectionPoint = typeof inflectionOrProfile === 'number'
3451
+ ? inflectionOrProfile
3452
+ : (inflectionOrProfile?.inflectionPoint ?? 0.72);
3453
+
3454
+ const k = typeof inflectionOrProfile === 'object' && inflectionOrProfile?.steepness !== undefined
3455
+ ? inflectionOrProfile.steepness
3456
+ : steepness;
3457
+
3449
3458
  const asymptote = 99.9;
3450
- const raw = 1.0 / (1.0 + Math.exp(steepness * (s - inflectionPoint)));
3451
- const minVal = 1.0 / (1.0 + Math.exp(steepness * (1.0 - inflectionPoint)));
3452
- const maxVal = 1.0 / (1.0 + Math.exp(steepness * (0.0 - inflectionPoint)));
3459
+ const raw = 1.0 / (1.0 + Math.exp(k * (s - inflectionPoint)));
3460
+ const minVal = 1.0 / (1.0 + Math.exp(k * (1.0 - inflectionPoint)));
3461
+ const maxVal = 1.0 / (1.0 + Math.exp(k * (0.0 - inflectionPoint)));
3453
3462
  const normalized = ((raw - minVal) / (maxVal - minVal)) * asymptote;
3454
3463
 
3455
3464
  return Math.min(asymptote, Math.round(normalized * 10.0) / 10.0);
@@ -3625,13 +3634,14 @@ async function getBotnetClusterScore(context, stableFpHash) {
3625
3634
  */
3626
3635
  async function getIpReputationScore(ip) {
3627
3636
  const key = `ip-reputation:${ip}`;
3637
+ const netProfile = defaultAsnLookup.lookup(ip);
3628
3638
  const data = await store.get(key);
3629
- if (!data) return 0;
3639
+ if (!data) return netProfile.baseScore;
3630
3640
 
3631
3641
  const now = Date.now();
3632
3642
  const hoursPassed = (now - data.lastUpdate) / (1000 * 60 * 60);
3633
3643
  const decay = Math.floor(hoursPassed * 2); // Decay 2 points per hour of inactivity
3634
- return Math.max(0, data.score - decay);
3644
+ return Math.max(netProfile.baseScore, data.score - decay);
3635
3645
  }
3636
3646
 
3637
3647
  /**
@@ -3962,7 +3972,7 @@ async function resolveRequestIdentity(context, securityConfig = {}) {
3962
3972
  * @param {object} deviceData - The device's activity data.
3963
3973
  * @returns {Promise<{historyScore: number, rotationScore: number}>}
3964
3974
  */
3965
- async function getBehavioralIndicators(context, deviceData) {
3975
+ async function getBehavioralIndicators(context, deviceData, netProfile = null) {
3966
3976
  const rapidChangeThresholdMs = 2000; // 2 secondes
3967
3977
  const maxRapidChanges = 3;
3968
3978
 
@@ -4040,13 +4050,18 @@ async function getBehavioralIndicators(context, deviceData) {
4040
4050
  : MAX_DISTINCT_IPS_PER_DEVICE;
4041
4051
  const freeIpChanges = isSharedIp ? 1 : 3;
4042
4052
 
4043
- const historyScore = Math.min(
4053
+ let historyScore = Math.min(
4044
4054
  100,
4045
4055
  (Math.max(0, deviceData.ips.size - freeIpChanges) /
4046
4056
  (maxIpsForDevice - freeIpChanges)) *
4047
4057
  100,
4048
4058
  );
4049
4059
 
4060
+ // Mobile CGNAT : désactivation/amortissement de la pénalité de rotation rapide d'IP
4061
+ if (netProfile?.toleranceRotation) {
4062
+ historyScore = Math.min(historyScore, 10.0);
4063
+ }
4064
+
4050
4065
  // Score based on rapid identity rotation (0-100)
4051
4066
  const rotationScore = Math.min(
4052
4067
  100,
@@ -4177,6 +4192,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
4177
4192
  // Execute non-interdependent asynchronous operations in parallel
4178
4193
  const zkpProof = (context.getHeader ? context.getHeader('x-zkp-proof') : null) || (context.headers ? context.headers['x-zkp-proof'] : null) || (context.query ? context.query['pow_zkp'] : null) || (context.queryParams ? context.queryParams['pow_zkp'] : null) || '';
4179
4194
  const zkpY = zkpProof ? zkpProof.split(":")[0] : null;
4195
+ const netProfile = defaultAsnLookup.lookup(clientIp);
4180
4196
 
4181
4197
  const [
4182
4198
  behavioral,
@@ -4187,7 +4203,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
4187
4203
  { botnetClusterScore },
4188
4204
  _ // store.set result
4189
4205
  ] = await Promise.all([
4190
- getBehavioralIndicators(context, deviceData), // This modifies deviceData, so it must be done before saving deviceData
4206
+ getBehavioralIndicators(context, deviceData, netProfile), // This modifies deviceData, so it must be done before saving deviceData
4191
4207
  getThreatIntelScore(context, zkpY, securityConfig), // NOUVEAU: Score de Threat Intelligence Fédéré
4192
4208
  getTlsSpoofingScore(context),
4193
4209
  getSubnetScore(context, deviceId, securityConfig),
@@ -4198,7 +4214,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
4198
4214
 
4199
4215
  // Synchronous calculations
4200
4216
  const { headerAnomalyScore } = getHeaderAnomalies(context);
4201
- const similarityThreshold = securityConfig?.similarityThreshold ?? 0.72;
4217
+ const similarityThreshold = securityConfig?.similarityThreshold ?? netProfile.inflectionPoint;
4202
4218
  const inconsistencyScore = calculateAnalogInconsistencyScore(consistencyScore, similarityThreshold);
4203
4219
 
4204
4220
  const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
@@ -5352,9 +5368,20 @@ export class FingerprintEngine {
5352
5368
  return { action: 'next', score: 0, vector: {} };
5353
5369
  }
5354
5370
 
5355
- // --- PRIVATE ACCESS TOKENS (PAT / RFC 9505 & Privacy Pass) ZERO-FRICTION BYPASS ---
5371
+ // --- PRIVATE ACCESS TOKENS (PAT / RFC 9578 & Privacy Pass) ZERO-FRICTION CHALLENGE EXEMPTION ---
5356
5372
  const rawPatTokens = extractPrivateAccessTokens(requestContext.headers);
5373
+ let hasValidPat = false;
5357
5374
  if (rawPatTokens.length > 0) {
5375
+ // Même avec un PAT valide, une attaque applicative avérée ou un terminal condamné doit être neutralisé
5376
+ if (deviceData?.condemned || this._hasCertainAttack(requestContext)) {
5377
+ return {
5378
+ action: 'block',
5379
+ status: 404,
5380
+ body: 'Forbidden',
5381
+ score: 100,
5382
+ vector: { honeypotScore: 100 }
5383
+ };
5384
+ }
5358
5385
  const patConfig = this.securityConfig.pat || {};
5359
5386
  for (const rawToken of rawPatTokens) {
5360
5387
  const parsedToken = parsePrivateAccessToken(rawToken);
@@ -5367,18 +5394,12 @@ export class FingerprintEngine {
5367
5394
  // Anti-replay: cache nonce with 24-hour TTL
5368
5395
  const nonceTtl = patConfig.nonceTtl || 86400;
5369
5396
  await store.set(nonceKey, true, nonceTtl);
5370
- this._log('Private Access Token (PAT) cryptographically verified - granting zero-friction bypass', {
5397
+ this._log('Private Access Token (PAT) cryptographically verified - challenge exemption enabled', {
5371
5398
  tokenType: parsedToken.tokenType,
5372
5399
  tokenKeyId: parsedToken.tokenKeyId
5373
5400
  });
5374
- const decision = {
5375
- action: 'next',
5376
- score: 0.0,
5377
- vector: { pat_verified: 100.0, privacy_pass: 100.0 },
5378
- intendedAction: 'next'
5379
- };
5380
- if (newCookie) decision.newCookieForResponse = newCookie;
5381
- return decision;
5401
+ hasValidPat = true;
5402
+ break;
5382
5403
  }
5383
5404
  }
5384
5405
  }
@@ -5500,6 +5521,9 @@ export class FingerprintEngine {
5500
5521
  // This is a strong signal of spoofing.
5501
5522
  suspicionVector.tlsSpoofingScore = Math.max(suspicionVector.tlsSpoofingScore || 0, 95);
5502
5523
  }
5524
+ if (hasValidPat) {
5525
+ suspicionVector.pat_verified = 100.0;
5526
+ }
5503
5527
  let finalScore = preCalculatedScore !== null ? preCalculatedScore : this.calculateFinalScore(suspicionVector);
5504
5528
 
5505
5529
  this._log('Suspicion vector calculated', {
@@ -5988,7 +6012,7 @@ export class FingerprintEngine {
5988
6012
  // OU
5989
6013
  // 2. La requête est très suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
5990
6014
  const zkpProof = requestContext.headers['x-zkp-proof'] || query.pow_zkp || '';
5991
- const hasValidTicket = await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming, zkpProof);
6015
+ const hasValidTicket = (await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming, zkpProof)) || hasValidPat;
5992
6016
  // Correction : Pour éviter une boucle infinie de challenges (qui mène à l'erreur 429),
5993
6017
  // on fait confiance au ticket valide tant qu'il n'a pas expiré.
5994
6018
  const maxIndicatorsCount = Object.values(suspicionVector).filter(val => typeof val === 'number' && val >= 100).length;
@@ -7382,6 +7406,8 @@ export const powMiddleware = (securityConfig) => {
7382
7406
  * This is a common pattern to allow mocking of ES module functions.
7383
7407
  */
7384
7408
  export const __internal = {
7409
+ defaultAsnLookup,
7410
+ NetworkProfile,
7385
7411
  get store() { return store; }, // Export the store for testing
7386
7412
  checkChallengeRateLimit,
7387
7413
  getDeviceHash,
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AsnLookupEngine, NetworkProfile } from '../asn-lookup.js';
3
+ import { calculateAnalogInconsistencyScore } from '../fingerprint.js';
4
+
5
+ describe('Native AsnLookupEngine Patricia Trie', () => {
6
+ const engine = new AsnLookupEngine();
7
+
8
+ it('should resolve Hetzner and AWS Datacenter IPs under 50ns with prior score 55.0', () => {
9
+ const hetznerProfile = engine.lookup('95.216.12.34');
10
+ expect(hetznerProfile.type).toBe('HOSTING');
11
+ expect(hetznerProfile.baseScore).toBe(55.0);
12
+ expect(hetznerProfile.inflectionPoint).toBe(0.85);
13
+
14
+ const awsProfile = engine.lookup('54.210.1.20');
15
+ expect(awsProfile.type).toBe('HOSTING');
16
+ });
17
+
18
+ it('should identify Mobile CGNAT 100.64.0.0/10 with rotation tolerance', () => {
19
+ const cgnatProfile = engine.lookup('100.70.1.25');
20
+ expect(cgnatProfile.type).toBe('CELLULAR');
21
+ expect(cgnatProfile.toleranceRotation).toBe(true);
22
+ expect(cgnatProfile.inflectionPoint).toBe(0.60);
23
+ });
24
+
25
+ it('should identify Starlink Satellite ranges with elevated jitter tolerance', () => {
26
+ const starlinkProfile = engine.lookup('98.97.10.5');
27
+ expect(starlinkProfile.type).toBe('SATELLITE');
28
+ expect(starlinkProfile.jitterTolerance).toBe(150.0);
29
+ });
30
+
31
+ it('should default unknown residential IP to 0.0 base score', () => {
32
+ const resProfile = engine.lookup('82.120.45.67');
33
+ expect(resProfile.type).toBe('RESIDENTIAL');
34
+ expect(resProfile.baseScore).toBe(0.0);
35
+ expect(resProfile.inflectionPoint).toBe(0.72);
36
+ });
37
+
38
+ describe('Analog Inconsistency Modulation', () => {
39
+ it('should be significantly more sensitive on Datacenter IP than Mobile for identical consistency', () => {
40
+ const consistency = 0.80; // Légère incohérence de rendu (ex: Canvas altéré)
41
+
42
+ const datacenterProfile = NetworkProfile.HOSTING; // inflectionPoint = 0.85
43
+ const mobileProfile = NetworkProfile.CELLULAR; // inflectionPoint = 0.60
44
+ const residentialProfile = NetworkProfile.RESIDENTIAL; // inflectionPoint = 0.72
45
+
46
+ const scoreDatacenter = calculateAnalogInconsistencyScore(consistency, datacenterProfile);
47
+ const scoreResidential = calculateAnalogInconsistencyScore(consistency, residentialProfile);
48
+ const scoreMobile = calculateAnalogInconsistencyScore(consistency, mobileProfile);
49
+
50
+ // Sur Datacenter (0.80 < 0.85), la suspicion décolle immédiatement
51
+ expect(scoreDatacenter).toBeGreaterThan(55.0);
52
+ // Sur Mobile (0.80 > 0.60), la variation est entièrement absorbée
53
+ expect(scoreMobile).toBeLessThan(10.0);
54
+ expect(scoreDatacenter).toBeGreaterThan(scoreResidential);
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,173 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ class AsnTrieNode
8
+ {
9
+ /** @var array<?AsnTrieNode> */
10
+ public array $children = [null, null];
11
+ public ?NetworkProfile $profile = null;
12
+ }
13
+
14
+ /**
15
+ * Patricia Trie / Radix Trie en mémoire vive pour résolution ultra-rapide (< 50ns)
16
+ * sans appel réseau bloquant.
17
+ */
18
+ class AsnLookupEngine
19
+ {
20
+ private static ?self $instance = null;
21
+ private AsnTrieNode $rootV4;
22
+ private AsnTrieNode $rootV6;
23
+
24
+ public function __construct()
25
+ {
26
+ $this->rootV4 = new AsnTrieNode();
27
+ $this->rootV6 = new AsnTrieNode();
28
+ $this->loadDefaultPrefixes();
29
+ }
30
+
31
+ public static function getInstance(): self
32
+ {
33
+ if (self::$instance === null) {
34
+ self::$instance = new self();
35
+ }
36
+ return self::$instance;
37
+ }
38
+
39
+ public function insert(string $cidr, NetworkProfile $profile): void
40
+ {
41
+ $parts = explode('/', trim($cidr), 2);
42
+ $ip = $parts[0];
43
+ $isV4 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
44
+ $isV6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
45
+ if (!$isV4 && !$isV6) {
46
+ return;
47
+ }
48
+
49
+ $maxBits = $isV4 ? 32 : 128;
50
+ $prefixLen = isset($parts[1]) ? (int)$parts[1] : $maxBits;
51
+ if ($prefixLen < 0 || $prefixLen > $maxBits) {
52
+ return;
53
+ }
54
+
55
+ $bytes = inet_pton($ip);
56
+ if ($bytes === false) {
57
+ return;
58
+ }
59
+
60
+ $current = $isV4 ? $this->rootV4 : $this->rootV6;
61
+ for ($i = 0; $i < $prefixLen; $i++) {
62
+ $byteIdx = (int)($i / 8);
63
+ $bitIdx = 7 - ($i % 8);
64
+ $bit = (ord($bytes[$byteIdx]) >> $bitIdx) & 1;
65
+
66
+ if ($current->children[$bit] === null) {
67
+ $current->children[$bit] = new AsnTrieNode();
68
+ }
69
+ $current = $current->children[$bit];
70
+ }
71
+
72
+ $current->profile = $profile;
73
+ }
74
+
75
+ public function lookup(?string $ip): NetworkProfile
76
+ {
77
+ if (empty($ip)) {
78
+ return NetworkProfile::residential();
79
+ }
80
+
81
+ $cleanIp = trim($ip);
82
+ if (str_starts_with(strtolower($cleanIp), '::ffff:')) {
83
+ $cleanIp = substr($cleanIp, 7);
84
+ }
85
+
86
+ $isV4 = filter_var($cleanIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
87
+ $isV6 = filter_var($cleanIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
88
+ if (!$isV4 && !$isV6) {
89
+ return NetworkProfile::residential();
90
+ }
91
+
92
+ $bytes = inet_pton($cleanIp);
93
+ if ($bytes === false) {
94
+ return NetworkProfile::residential();
95
+ }
96
+
97
+ $current = $isV4 ? $this->rootV4 : $this->rootV6;
98
+ $matchedProfile = null;
99
+ $totalBits = $isV4 ? 32 : 128;
100
+
101
+ for ($i = 0; $i < $totalBits; $i++) {
102
+ if ($current->profile !== null) {
103
+ $matchedProfile = $current->profile; // Longest Prefix Match
104
+ }
105
+
106
+ $byteIdx = (int)($i / 8);
107
+ $bitIdx = 7 - ($i % 8);
108
+ $bit = (ord($bytes[$byteIdx]) >> $bitIdx) & 1;
109
+
110
+ $current = $current->children[$bit];
111
+ if ($current === null) {
112
+ break;
113
+ }
114
+ }
115
+
116
+ if ($current !== null && $current->profile !== null) {
117
+ $matchedProfile = $current->profile;
118
+ }
119
+
120
+ return $matchedProfile ?? NetworkProfile::residential();
121
+ }
122
+
123
+ public function loadDefaultPrefixes(): void
124
+ {
125
+ // Mobile CGNAT (RFC 6598)
126
+ $this->insert('100.64.0.0/10', NetworkProfile::cellular());
127
+
128
+ // Datacenters & Cloud majeurs (AWS, Hetzner, OVH, DigitalOcean)
129
+ $datacenterRanges = [
130
+ // AWS
131
+ '3.0.0.0/9', '3.128.0.0/9', '18.192.0.0/11', '34.192.0.0/10',
132
+ '35.156.0.0/14', '52.0.0.0/11', '54.0.0.0/8',
133
+ // Hetzner
134
+ '78.46.0.0/15', '88.198.0.0/16', '94.130.0.0/16', '95.216.0.0/15',
135
+ '116.202.0.0/15', '135.181.0.0/16', '136.243.0.0/16', '138.201.0.0/16',
136
+ '142.132.0.0/16', '144.76.0.0/16', '148.251.0.0/16', '159.69.0.0/16',
137
+ '168.119.0.0/16', '178.63.0.0/16', '188.40.0.0/16', '195.201.0.0/16',
138
+ // OVH
139
+ '51.68.0.0/14', '51.75.0.0/15', '51.77.0.0/16', '51.79.0.0/16',
140
+ '51.81.0.0/16', '51.83.0.0/16', '51.89.0.0/16', '51.91.0.0/16',
141
+ '137.74.0.0/16', '141.94.0.0/15', '145.239.0.0/16', '147.135.0.0/16',
142
+ '176.31.0.0/16', '178.32.0.0/15', '188.165.0.0/16', '198.27.64.0/18',
143
+ // DigitalOcean
144
+ '64.225.0.0/16', '68.183.0.0/16', '104.248.0.0/16', '128.199.0.0/16',
145
+ '134.209.0.0/16', '138.68.0.0/16', '138.197.0.0/16', '139.59.0.0/16',
146
+ '142.93.0.0/16', '143.198.0.0/16', '146.190.0.0/16', '157.230.0.0/16',
147
+ '159.65.0.0/16', '159.89.0.0/16', '161.35.0.0/16', '164.90.128.0/17',
148
+ '165.22.0.0/16', '165.227.0.0/16', '167.99.0.0/16', '174.138.0.0/16',
149
+ '178.62.0.0/16', '178.128.0.0/16', '188.166.0.0/16', '206.189.0.0/16',
150
+ ];
151
+ foreach ($datacenterRanges as $range) {
152
+ $this->insert($range, NetworkProfile::hosting());
153
+ }
154
+
155
+ // Satellite (Starlink Space-X)
156
+ $satelliteRanges = [
157
+ '98.97.0.0/16', '129.222.0.0/16', '143.130.0.0/16',
158
+ '143.244.0.0/16', '206.214.224.0/19'
159
+ ];
160
+ foreach ($satelliteRanges as $range) {
161
+ $this->insert($range, NetworkProfile::satellite());
162
+ }
163
+
164
+ // Tor Exit Relays / Proxies anonymiseurs
165
+ $anonymizerRanges = [
166
+ '185.220.101.0/24', '185.220.102.0/24', '185.220.103.0/24',
167
+ '185.100.86.128/25', '198.98.56.0/24', '199.249.230.0/24'
168
+ ];
169
+ foreach ($anonymizerRanges as $range) {
170
+ $this->insert($range, NetworkProfile::anonymizer());
171
+ }
172
+ }
173
+ }
@@ -353,19 +353,19 @@ class ChallengeUtils
353
353
  $expectedMsg = "{$clientSecret}:find_peer:{$nodeId}";
354
354
  break;
355
355
  case 'webrtc_signal':
356
- $expectedMsg = "{$clientSecret}:webrtc_signal:{$nodeId}:" . ($params['target_peer_id'] ?? '') . ":" . ($params['signal_type'] ?? '') . ":" . ($params['signal_data'] ?? '');
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']) : '');
357
357
  break;
358
358
  case 'poll_signals':
359
359
  $expectedMsg = "{$clientSecret}:poll_signals:{$nodeId}";
360
360
  break;
361
361
  case 'request_peer_block':
362
- $expectedMsg = "{$clientSecret}:request_peer_block:{$nodeId}:" . ($params['peer_id'] ?? '') . ":" . ($params['block_idx'] ?? '0') . ":" . ($params['req_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']) : '');
363
363
  break;
364
364
  case 'poll_requests':
365
365
  $expectedMsg = "{$clientSecret}:poll_requests:{$nodeId}";
366
366
  break;
367
367
  case 'respond_block':
368
- $expectedMsg = "{$clientSecret}:respond_block:{$nodeId}:" . ($params['requester_id'] ?? '') . ":" . ($params['req_id'] ?? '') . ":" . ($params['block_data'] ?? '');
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']) : '');
369
369
  break;
370
370
  case 'poll_response':
371
371
  $expectedMsg = "{$clientSecret}:poll_response:{$nodeId}:" . ($params['req_id'] ?? '');
@@ -383,7 +383,7 @@ class ChallengeUtils
383
383
  switch ($op) {
384
384
  case 'register':
385
385
  $clientIp = self::getSanitizedServerVar('REMOTE_ADDR', '127.0.0.1');
386
- $seed = $params['seed'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
386
+ $seed = isset($params['seed']) ? self::sanitizeString($params['seed']) : '';
387
387
  self::registerCooperativeNode($clientIp, $nodeId, $seed);
388
388
  return ['status' => 'registered'];
389
389
 
@@ -395,9 +395,9 @@ class ChallengeUtils
395
395
  return ['status' => 'no_peers'];
396
396
 
397
397
  case 'webrtc_signal':
398
- $targetPeerId = $params['target_peer_id'] ?? '';
399
- $signalType = $params['signal_type'] ?? '';
400
- $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']) : '';
401
401
  if (empty($targetPeerId) || empty($signalType) || empty($signalData)) {
402
402
  return ['error' => 'Invalid parameters'];
403
403
  }
@@ -420,9 +420,9 @@ class ChallengeUtils
420
420
  return ['status' => 'ok', 'signals' => $signals];
421
421
 
422
422
  case 'request_peer_block':
423
- $peerId = $params['peer_id'] ?? '';
423
+ $peerId = isset($params['peer_id']) ? self::sanitizeString($params['peer_id']) : '';
424
424
  $blockIdx = (int)($params['block_idx'] ?? 0);
425
- $requestId = $params['req_id'] ?? '';
425
+ $requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
426
426
  if (empty($peerId) || empty($requestId)) {
427
427
  return ['error' => 'Invalid parameters'];
428
428
  }
@@ -444,9 +444,9 @@ class ChallengeUtils
444
444
  return ['requests' => $requests];
445
445
 
446
446
  case 'respond_block':
447
- $requesterId = $params['requester_id'] ?? '';
448
- $requestId = $params['req_id'] ?? '';
449
- $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']) : '';
450
450
  if (empty($requesterId) || empty($requestId)) {
451
451
  return ['error' => 'Invalid parameters'];
452
452
  }
@@ -456,7 +456,7 @@ class ChallengeUtils
456
456
  return ['status' => 'delivered'];
457
457
 
458
458
  case 'poll_response':
459
- $requestId = $params['req_id'] ?? '';
459
+ $requestId = isset($params['req_id']) ? self::sanitizeString($params['req_id']) : '';
460
460
  $responseKey = "coop-mailbox:res:{$nodeId}:{$requestId}";
461
461
  $data = $store->get($responseKey);
462
462
  if ($data) {
@@ -634,28 +634,27 @@ class ChallengeUtils
634
634
  throw new \RuntimeException("Failed to extract Ed25519 public key.");
635
635
  }
636
636
 
637
- if ($outDir === '') {
638
- $outDir = dirname(__DIR__, 2) . '/config';
639
- }
640
-
641
- if (!is_dir($outDir)) {
642
- if (function_exists('wp_mkdir_p')) {
643
- wp_mkdir_p($outDir);
644
- } else {
645
- mkdir($outDir, 0755, true);
646
- }
647
- }
648
-
649
637
  $privName = $options['privateKeyName'] ?? 'issuer-private.pem';
650
638
  $pubName = $options['publicKeyName'] ?? 'issuer-public.pem';
651
639
 
652
- $privateKeyPath = rtrim($outDir, '/\\') . '/' . $privName;
653
- $publicKeyPath = rtrim($outDir, '/\\') . '/' . $pubName;
640
+ $privateKeyPath = '';
641
+ $publicKeyPath = '';
654
642
 
655
- file_put_contents($privateKeyPath, $privateKeyPem);
656
- @chmod($privateKeyPath, 0600);
657
- file_put_contents($publicKeyPath, $publicKeyPem);
658
- @chmod($publicKeyPath, 0644);
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
+ }
659
658
 
660
659
  return [
661
660
  'privateKeyPath' => $privateKeyPath,
@@ -1294,20 +1293,25 @@ class ChallengeUtils
1294
1293
  */
1295
1294
  private static function getPowSolverCode(): string
1296
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
+
1297
1308
  $candidates = [
1298
1309
  __DIR__ . '/../../js/pow.solver.inline.js',
1299
1310
  __DIR__ . '/../assets/pow.solver.inline.js',
1300
1311
  __DIR__ . '/../js/pow.solver.inline.js',
1301
- dirname(__DIR__, 2) . '/assets/pow.solver.inline.js',
1302
- dirname(__DIR__, 2) . '/js/pow.solver.inline.js',
1303
- dirname(__DIR__, 1) . '/assets/pow.solver.inline.js',
1312
+ dirname(__DIR__) . '/assets/pow.solver.inline.js',
1313
+ dirname(__DIR__) . '/js/pow.solver.inline.js',
1304
1314
  ];
1305
- if (defined('ABSPATH')) {
1306
- $wpPluginSolver = ABSPATH . 'wp-content/plugins/fingerprint-anti-bot/assets/pow.solver.inline.js';
1307
- if (file_exists($wpPluginSolver)) {
1308
- return file_get_contents($wpPluginSolver) ?: '';
1309
- }
1310
- }
1311
1315
  foreach ($candidates as $solverPath) {
1312
1316
  if (file_exists($solverPath)) {
1313
1317
  return file_get_contents($solverPath) ?: '';
@@ -1493,6 +1497,27 @@ class ChallengeUtils
1493
1497
  error_log($message);
1494
1498
  }
1495
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
+
1496
1521
  /**
1497
1522
  * Safely retrieves and sanitizes a value from the $_SERVER superglobal.
1498
1523
  * Uses WordPress functions if available, otherwise falls back to basic PHP sanitization.
@@ -1509,14 +1534,6 @@ class ChallengeUtils
1509
1534
 
1510
1535
  // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed and sanitized immediately below
1511
1536
  $value = $_SERVER[$key];
1512
-
1513
- if (function_exists('wp_unslash')) {
1514
- $value = wp_unslash($value);
1515
- }
1516
- if (function_exists('sanitize_text_field')) {
1517
- return sanitize_text_field($value);
1518
- }
1519
-
1520
- return is_scalar($value) ? (string) $value : '';
1537
+ return self::sanitizeString($value);
1521
1538
  }
1522
1539
  }