@anonympins/fingerprint 0.8.1 → 0.8.2

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.
@@ -3885,9 +3885,27 @@ let store = inMemoryStore;
3885
3885
  * @param {IStore} externalStore - An implementation of the IStore interface.
3886
3886
  */
3887
3887
  export const configureStore = (externalStore) => {
3888
+ if (externalStore && typeof externalStore.has !== 'function' && typeof externalStore.get === 'function') {
3889
+ externalStore.has = async function (key) {
3890
+ const val = await this.get(key);
3891
+ return val !== null && val !== undefined;
3892
+ };
3893
+ }
3888
3894
  store = externalStore;
3889
3895
  };
3890
3896
 
3897
+ async function storeHas(key) {
3898
+ if (!store) return false;
3899
+ if (typeof store.has === 'function') {
3900
+ return await store.has(key);
3901
+ }
3902
+ if (typeof store.get === 'function') {
3903
+ const val = await store.get(key);
3904
+ return val !== null && val !== undefined;
3905
+ }
3906
+ return false;
3907
+ }
3908
+
3891
3909
  /**
3892
3910
  * Orchestrates request identification using a persistent anchor (cookie)
3893
3911
  * and fingerprint verification.
@@ -4706,7 +4724,7 @@ async function getThreatIntelScore(context, zkpY, threatIntelConfig = {}) {
4706
4724
 
4707
4725
  // 1. Détection déterministe : Clé publique ZKP bannie par consensus fédéré
4708
4726
  if (zkpY) {
4709
- const isBanned = await store.has(`banned-zkp-y:${zkpY}`);
4727
+ const isBanned = await storeHas(`banned-zkp-y:${zkpY}`);
4710
4728
  if (isBanned) {
4711
4729
  score = 100.0;
4712
4730
  signals.push({
@@ -5252,12 +5270,16 @@ export class FingerprintEngine {
5252
5270
 
5253
5271
  const { clientIp = "unknown", path, cookies = {}, query = {}, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
5254
5272
 
5255
- if (query.coop_op) {
5273
+ const queryCoopOp = query.coop_op || (typeof requestContext.body === 'object' && requestContext.body ? requestContext.body.coop_op : null);
5274
+ if (queryCoopOp) {
5275
+ const bodyObj = typeof requestContext.body === 'object' && requestContext.body !== null ? requestContext.body : {};
5256
5276
  const coopParams = {
5257
5277
  ...query,
5258
- signature: requestContext.headers?.['x-federation-signature'] || query.signature,
5259
- signature_ed25519: requestContext.headers?.['x-federation-signature-ed25519'] || query.signature_ed25519,
5260
- timestamp: requestContext.headers?.['x-federation-timestamp'] || query.timestamp
5278
+ ...bodyObj,
5279
+ coop_op: queryCoopOp,
5280
+ signature: requestContext.headers?.['x-federation-signature'] || query.signature || bodyObj.signature,
5281
+ signature_ed25519: requestContext.headers?.['x-federation-signature-ed25519'] || query.signature_ed25519 || bodyObj.signature_ed25519,
5282
+ timestamp: requestContext.headers?.['x-federation-timestamp'] || query.timestamp || bodyObj.timestamp
5261
5283
  };
5262
5284
  const result = await handleCooperativeRequest(coopParams, clientIp, this.securityConfig);
5263
5285
  return {
@@ -5387,7 +5409,7 @@ export class FingerprintEngine {
5387
5409
  const parsedToken = parsePrivateAccessToken(rawToken);
5388
5410
  if (parsedToken) {
5389
5411
  const nonceKey = `pat-nonce:${parsedToken.nonce}`;
5390
- const isReplayed = await store.has(nonceKey);
5412
+ const isReplayed = await storeHas(nonceKey);
5391
5413
  if (!isReplayed) {
5392
5414
  const isValid = verifyPatSignature(parsedToken, patConfig);
5393
5415
  if (isValid) {
@@ -5415,6 +5437,41 @@ export class FingerprintEngine {
5415
5437
  let whitelisted = false;
5416
5438
  let whitelistType = '';
5417
5439
 
5440
+ // Auto-whitelisting des pairs fédérés
5441
+ const federatedPeers = this.securityConfig.federatedPeers || [];
5442
+ if (federatedPeers.length > 0) {
5443
+ for (const peerUrl of federatedPeers) {
5444
+ try {
5445
+ if (new URL(peerUrl).hostname === clientIp || peerUrl === clientIp) {
5446
+ whitelisted = true;
5447
+ whitelistType = 'federated_peer';
5448
+ break;
5449
+ }
5450
+ } catch (e) {
5451
+ if (peerUrl === clientIp) {
5452
+ whitelisted = true;
5453
+ whitelistType = 'federated_peer';
5454
+ break;
5455
+ }
5456
+ }
5457
+ }
5458
+ }
5459
+
5460
+ if (!whitelisted) {
5461
+ const subnet = getIpSubnet(clientIp);
5462
+ const ua = requestContext.headers?.['user-agent'] || '';
5463
+ if (await storeHas(`federated-whitelist:ip:${clientIp}`)) {
5464
+ whitelisted = true;
5465
+ whitelistType = 'federated_whitelist_ip';
5466
+ } else if (subnet && await storeHas(`federated-whitelist:subnet:${subnet}`)) {
5467
+ whitelisted = true;
5468
+ whitelistType = 'federated_whitelist_subnet';
5469
+ } else if (ua && await storeHas(`federated-whitelist:user_agent:${ua}`)) {
5470
+ whitelisted = true;
5471
+ whitelistType = 'federated_whitelist_user_agent';
5472
+ }
5473
+ }
5474
+
5418
5475
  if (this._isIpInAllowlist(clientIp)) {
5419
5476
  whitelisted = true;
5420
5477
  whitelistType = 'allowlist';
@@ -5912,7 +5969,7 @@ export class FingerprintEngine {
5912
5969
 
5913
5970
  const threshold = this.securityConfig.federationConsensusThreshold || 3;
5914
5971
  if (reportedPeers.length >= threshold) {
5915
- if (!(await store.has(`banned-zkp-y:${zkpY}`))) {
5972
+ if (!(await storeHas(`banned-zkp-y:${zkpY}`))) {
5916
5973
  await store.set(`banned-zkp-y:${zkpY}`, true, 86400 * 30); // Banni pour 30 jours
5917
5974
  }
5918
5975
  }
@@ -6459,6 +6516,62 @@ export async function handleCooperativeRequest(params, clientIp = '127.0.0.1', c
6459
6516
  return { status: 'synchronized', banned: false, reportsCount: reportedPeers.length };
6460
6517
  }
6461
6518
 
6519
+ if (op === 'share_whitelist') {
6520
+ const peers = config.federatedPeers || [];
6521
+ if (peers.length > 0) {
6522
+ const allowedIPsAndHosts = peers.map(url => {
6523
+ try { return new URL(url).hostname; } catch(e) { return url; }
6524
+ });
6525
+ if (!allowedIPsAndHosts.includes(clientIp)) {
6526
+ return { error: 'Unauthorized federation sender IP' };
6527
+ }
6528
+ }
6529
+ const entry = params.entry;
6530
+ const entryType = params.entry_type || 'ip'; // ip, subnet, user_agent
6531
+ const sigEd25519 = params.signature_ed25519;
6532
+ const timestampStr = params.timestamp;
6533
+ const ttlStr = params.ttl || '86400';
6534
+
6535
+ if (!entry || !entry.trim() || !sigEd25519 || !timestampStr) {
6536
+ return { error: 'Missing required parameters for share_whitelist' };
6537
+ }
6538
+
6539
+ const trimmedEntry = entry.trim();
6540
+ if (trimmedEntry === '*' || trimmedEntry === '0.0.0.0/0' || trimmedEntry === '::/0') {
6541
+ return { error: 'Permissive wildcard entries are prohibited' };
6542
+ }
6543
+
6544
+ const timestamp = Number(timestampStr);
6545
+ if (isNaN(timestamp) || Math.abs(Date.now() - timestamp) > 300000) {
6546
+ return { error: 'Timestamp expired or clock skew too high' };
6547
+ }
6548
+
6549
+ const publicKey = config.ed25519_public_key || process.env.ED25519_PUBLIC_KEY;
6550
+ if (!publicKey) {
6551
+ return { error: 'Missing public key for asymmetric verification' };
6552
+ }
6553
+
6554
+ const msg = `${timestamp}:whitelist:${entryType}:${trimmedEntry}`;
6555
+ try {
6556
+ const cleanKey = publicKey.replace(/\\n/g, '\n');
6557
+ const isVerified = crypto.verify(
6558
+ null,
6559
+ Buffer.from(msg, 'utf8'),
6560
+ { key: cleanKey, format: 'pem', type: 'spki' },
6561
+ Buffer.from(sigEd25519, 'hex')
6562
+ );
6563
+ if (!isVerified) {
6564
+ return { error: 'Invalid Ed25519 signature for whitelist synchronization' };
6565
+ }
6566
+ } catch (e) {
6567
+ return { error: 'Asymmetric signature verification failed: ' + e.message };
6568
+ }
6569
+
6570
+ const ttl = Math.min(604800, parseInt(ttlStr, 10) || 86400);
6571
+ await store.set(`federated-whitelist:${entryType}:${trimmedEntry}`, true, ttl);
6572
+ return { status: 'whitelist_synchronized', entry: trimmedEntry, entryType, ttl };
6573
+ }
6574
+
6462
6575
  const nodeId = params.node_id || '';
6463
6576
  if (!nodeId) {
6464
6577
  return { error: 'Missing node_id' };
@@ -6600,6 +6713,51 @@ export async function handleCooperativeRequest(params, clientIp = '127.0.0.1', c
6600
6713
  return null;
6601
6714
  }
6602
6715
 
6716
+ export async function broadcastWhitelistedEntry(entry, entryType = 'ip', ttl = 86400, config = {}) {
6717
+ const peers = config.federatedPeers || [];
6718
+ if (peers.length === 0 || !entry) return;
6719
+
6720
+ const trimmedEntry = entry.trim();
6721
+ if (trimmedEntry === '*' || trimmedEntry === '0.0.0.0/0' || trimmedEntry === '::/0') {
6722
+ throw new Error('Permissive wildcard entries are prohibited');
6723
+ }
6724
+
6725
+ const ed25519Key = process.env.ED25519_PRIVATE_KEY || config.ed25519_private_key;
6726
+ if (!ed25519Key) {
6727
+ throw new Error('ED25519_PRIVATE_KEY is required for federated whitelist broadcast');
6728
+ }
6729
+
6730
+ const ts = Date.now();
6731
+ const msg = `${ts}:whitelist:${entryType}:${trimmedEntry}`;
6732
+ const cleanKey = ed25519Key.replace(/\\n/g, '\n');
6733
+ const signature = crypto.sign(null, Buffer.from(msg, 'utf8'), {
6734
+ key: cleanKey,
6735
+ format: 'pem',
6736
+ type: 'pkcs8'
6737
+ }).toString('hex');
6738
+
6739
+ const boundedTtl = Math.min(604800, parseInt(ttl, 10) || 86400);
6740
+
6741
+ peers.forEach(peerUrl => {
6742
+ const url = `${peerUrl}?coop_op=share_whitelist`;
6743
+ fetch(url, {
6744
+ method: 'POST',
6745
+ headers: {
6746
+ 'Content-Type': 'application/json',
6747
+ 'X-Federation-Signature-Ed25519': signature,
6748
+ 'X-Federation-Timestamp': String(ts)
6749
+ },
6750
+ body: JSON.stringify({
6751
+ entry: trimmedEntry,
6752
+ entry_type: entryType,
6753
+ ttl: boundedTtl,
6754
+ signature_ed25519: signature,
6755
+ timestamp: ts
6756
+ })
6757
+ }).catch(() => {});
6758
+ });
6759
+ }
6760
+
6603
6761
 
6604
6762
  /** @type {Map<number, number>} Cache des TTL optimisés par score de suspicion (clés de 0 à 100 par pas de 10) */
6605
6763
  let optimizedTtlCache = new Map();
@@ -7464,6 +7622,7 @@ export const __internal = {
7464
7622
  findPeerInSubnet,
7465
7623
  handleCooperativeRequest,
7466
7624
  broadcastBannedZkp,
7625
+ broadcastWhitelistedEntry,
7467
7626
  generateIssuerPemKeys,
7468
7627
  getMetric,
7469
7628
  incrementCounter,
@@ -236,6 +236,31 @@ class ChallengeUtils
236
236
  return $activePeers[array_rand($activePeers)];
237
237
  }
238
238
 
239
+ public static function verifyEd25519Signature(string $message, string $signatureHex, array $config = []): bool
240
+ {
241
+ if (empty($message) || empty($signatureHex)) {
242
+ return false;
243
+ }
244
+ $publicKey = $config['ed25519_public_key'] ?? Env::get('ED25519_PUBLIC_KEY');
245
+ if (empty($publicKey)) {
246
+ return false;
247
+ }
248
+ try {
249
+ $cleanKey = str_replace('\n', "\n", $publicKey);
250
+ $pubKeyObj = openssl_pkey_get_public($cleanKey);
251
+ if (!$pubKeyObj) {
252
+ return false;
253
+ }
254
+ $sigBytes = hex2bin($signatureHex);
255
+ if ($sigBytes === false) {
256
+ return false;
257
+ }
258
+ return openssl_verify($message, $sigBytes, $pubKeyObj, null) === 1;
259
+ } catch (\Throwable $e) {
260
+ return false;
261
+ }
262
+ }
263
+
239
264
  /**
240
265
  * Handles cooperative peer-to-peer operations (federation threat intel sharing,
241
266
  * node registration, peer discovery, WebRTC signaling, block requests/responses).
@@ -261,7 +286,7 @@ class ChallengeUtils
261
286
  $peers = $config['federatedPeers'] ?? [];
262
287
  if (!empty($peers)) {
263
288
  $allowedHosts = array_map(function ($url) {
264
- // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
289
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Fallback for standalone PHP environments
265
290
  $host = function_exists('wp_parse_url') ? wp_parse_url($url, PHP_URL_HOST) : parse_url($url, PHP_URL_HOST);
266
291
  return !empty($host) ? $host : $url;
267
292
  }, $peers);
@@ -289,18 +314,8 @@ class ChallengeUtils
289
314
  $msg = "{$timestamp}:{$zkpY}";
290
315
 
291
316
  if (!empty($sigEd25519)) {
292
- $publicKey = $config['ed25519_public_key'] ?? Env::get('ED25519_PUBLIC_KEY');
293
- if (!$publicKey) {
294
- return ['error' => 'Missing public key for asymmetric verification'];
295
- }
296
- try {
297
- $cleanKey = str_replace('\n', "\n", $publicKey);
298
- $pubKeyObj = openssl_pkey_get_public($cleanKey);
299
- if (!$pubKeyObj || openssl_verify($msg, hex2bin($sigEd25519), $pubKeyObj, null) !== 1) {
300
- return ['error' => 'Invalid asymmetric federation signature'];
301
- }
302
- } catch (\Throwable $e) {
303
- return ['error' => 'Asymmetric signature verification failed'];
317
+ if (!self::verifyEd25519Signature($msg, $sigEd25519, $config)) {
318
+ return ['error' => 'Invalid asymmetric federation signature'];
304
319
  }
305
320
  } elseif (!empty($sigHmac)) {
306
321
  $secret = $params['federationSecret'] ?? $config['federationSecret'] ?? self::getPowSecret();
@@ -330,6 +345,51 @@ class ChallengeUtils
330
345
  return ['status' => 'synchronized', 'banned' => false, 'reportsCount' => count($reportedPeers)];
331
346
  }
332
347
 
348
+ if ($op === 'share_whitelist') {
349
+ $peers = $config['federatedPeers'] ?? [];
350
+ if (!empty($peers)) {
351
+ $allowedHosts = array_map(function ($url) {
352
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Fallback for standalone PHP environments
353
+ $host = function_exists('wp_parse_url') ? wp_parse_url($url, PHP_URL_HOST) : parse_url($url, PHP_URL_HOST);
354
+ return !empty($host) ? $host : $url;
355
+ }, $peers);
356
+
357
+ if (!in_array($clientIp, $allowedHosts, true)) {
358
+ return ['error' => 'Unauthorized federation sender IP'];
359
+ }
360
+ }
361
+
362
+ $entry = $params['entry'] ?? '';
363
+ $entryType = $params['entry_type'] ?? 'ip';
364
+ $headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
365
+ $sigEd25519 = $params['signature_ed25519'] ?? $headers['x-federation-signature-ed25519'] ?? self::getSanitizedServerVar('HTTP_X_FEDERATION_SIGNATURE_ED25519', '');
366
+ $timestamp = (int)($params['timestamp'] ?? $headers['x-federation-timestamp'] ?? self::getSanitizedServerVar('HTTP_X_FEDERATION_TIMESTAMP', 0));
367
+ $ttl = (int)($params['ttl'] ?? 86400);
368
+
369
+ $cleanEntry = trim($entry);
370
+ if ($cleanEntry === '' || empty($sigEd25519) || empty($timestamp)) {
371
+ return ['error' => 'Missing required parameters for share_whitelist'];
372
+ }
373
+
374
+ if ($cleanEntry === '*' || $cleanEntry === '0.0.0.0/0' || $cleanEntry === '::/0') {
375
+ return ['error' => 'Permissive wildcard entries are prohibited'];
376
+ }
377
+
378
+ $now = (int)(microtime(true) * 1000);
379
+ if (abs($now - $timestamp) > 300000) {
380
+ return ['error' => 'Timestamp expired or clock skew too high'];
381
+ }
382
+
383
+ $msg = "{$timestamp}:whitelist:{$entryType}:{$cleanEntry}";
384
+ if (!self::verifyEd25519Signature($msg, $sigEd25519, $config)) {
385
+ return ['error' => 'Invalid Ed25519 signature for whitelist synchronization'];
386
+ }
387
+
388
+ $boundedTtl = min(604800, max(60, $ttl));
389
+ $store->set("federated-whitelist:{$entryType}:{$cleanEntry}", true, $boundedTtl);
390
+ return ['status' => 'whitelist_synchronized', 'entry' => $cleanEntry, 'entryType' => $entryType, 'ttl' => $boundedTtl];
391
+ }
392
+
333
393
  $nodeId = $params['node_id'] ?? '';
334
394
  if (empty($nodeId)) {
335
395
  return ['error' => 'Missing node_id'];
@@ -645,15 +705,32 @@ class ChallengeUtils
645
705
  if (function_exists('wp_mkdir_p')) {
646
706
  wp_mkdir_p($outDir);
647
707
  } else {
708
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Standalone filesystem fallback
648
709
  mkdir($outDir, 0755, true);
649
710
  }
650
711
  }
651
712
  $privateKeyPath = rtrim($outDir, '/\\') . '/' . $privName;
652
713
  $publicKeyPath = rtrim($outDir, '/\\') . '/' . $pubName;
653
- file_put_contents($privateKeyPath, $privateKeyPem);
654
- @chmod($privateKeyPath, 0600);
655
- file_put_contents($publicKeyPath, $publicKeyPem);
656
- @chmod($publicKeyPath, 0644);
714
+
715
+ global $wp_filesystem;
716
+ if (empty($wp_filesystem) && defined('ABSPATH')) {
717
+ require_once ABSPATH . 'wp-admin/includes/file.php';
718
+ WP_Filesystem();
719
+ }
720
+
721
+ if (!empty($wp_filesystem) && is_object($wp_filesystem)) {
722
+ $wp_filesystem->put_contents($privateKeyPath, $privateKeyPem, 0600);
723
+ $wp_filesystem->put_contents($publicKeyPath, $publicKeyPem, 0644);
724
+ } else {
725
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Standalone filesystem fallback
726
+ file_put_contents($privateKeyPath, $privateKeyPem);
727
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Standalone filesystem fallback
728
+ @chmod($privateKeyPath, 0600);
729
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Standalone filesystem fallback
730
+ file_put_contents($publicKeyPath, $publicKeyPem);
731
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Standalone filesystem fallback
732
+ @chmod($publicKeyPath, 0644);
733
+ }
657
734
  }
658
735
 
659
736
  return [
@@ -1372,7 +1449,7 @@ class ChallengeUtils
1372
1449
  . '}' . "\n"
1373
1450
  . 'solve();';
1374
1451
 
1375
- return "<html><head><title>Security Check</title></head><body style=\"font-family:sans-serif; text-align:center; padding-top:50px;\"><h1>Security Check (Level 2)</h1><p>We are verifying your storage allocation. This may take a few seconds on first load.</p><div id=\"loader\" style=\"margin:20px;\">⚙️ Initializing storage space...</div><script>{$solverCode}</script><script>{$challengeScript}</script></body></html>";
1452
+ return "<html><head><title>Security check</title></head><body style=\"font-family:sans-serif; text-align:center; padding-top:50px;\"><h1>Security check (level 2)</h1><p>We are verifying your storage allocation. This may take a few seconds on first load.</p><div id=\"loader\" style=\"margin:20px;\">⚙️ Initializing storage space...</div><script>{$solverCode}</script><script>{$challengeScript}</script></body></html>";
1376
1453
  }
1377
1454
 
1378
1455
  /**
@@ -1471,7 +1548,7 @@ class ChallengeUtils
1471
1548
  . '}' . "\n"
1472
1549
  . 'solve();';
1473
1550
 
1474
- $htmlTemplate = '<html><head><title>Advanced Security Check</title></head><body style="font-family:sans-serif; text-align:center; padding-top:50px;"><h1>Enhanced Verification... (Level 2)</h1><p>Your activity requires an additional security check. This may take a few moments.</p><div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div><script><!-- FINGERPRINT_SOLVER_SCRIPT --></script><script><!-- FINGERPRINT_CHALLENGE_SCRIPT --></script><!-- FINGERPRINT_TRAPS --></body></html>';
1551
+ $htmlTemplate = '<html><head><title>Advanced security check</title></head><body style="font-family:sans-serif; text-align:center; padding-top:50px;"><h1>Enhanced verification... (level 2)</h1><p>Your activity requires an additional security check. This may take a few moments.</p><div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div><script><!-- FINGERPRINT_SOLVER_SCRIPT --></script><script><!-- FINGERPRINT_CHALLENGE_SCRIPT --></script><!-- FINGERPRINT_TRAPS --></body></html>';
1475
1552
  $customTemplatePath = $securityConfig['challengePagePath'] ?? null;
1476
1553
 
1477
1554
  if ($customTemplatePath && file_exists($customTemplatePath)) {
@@ -1515,6 +1592,10 @@ class ChallengeUtils
1515
1592
  if (function_exists('sanitize_text_field')) {
1516
1593
  return sanitize_text_field($val);
1517
1594
  }
1595
+ if (function_exists('wp_strip_all_tags')) {
1596
+ return trim(wp_strip_all_tags($val));
1597
+ }
1598
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- Standalone fallback when running outside WordPress
1518
1599
  return trim(strip_tags($val));
1519
1600
  }
1520
1601
 
@@ -267,6 +267,7 @@ class FingerprintEngine
267
267
  if (function_exists('wp_mkdir_p')) {
268
268
  wp_mkdir_p($configDir);
269
269
  } else {
270
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Standalone filesystem fallback
270
271
  @mkdir($configDir, 0700, true);
271
272
  }
272
273
  }
@@ -689,6 +690,21 @@ class FingerprintEngine
689
690
  */
690
691
  private function checkAllowlists(RequestContext $context): bool
691
692
  {
693
+ // Auto-whitelisting des pairs fédérés
694
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
695
+ if (!empty($peers)) {
696
+ $allowedHosts = array_map(function ($url) {
697
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Covered by wp_parse_url, Fallback for standalone PHP environments
698
+ $host = function_exists('wp_parse_url') ? wp_parse_url($url, PHP_URL_HOST) : parse_url($url, PHP_URL_HOST);
699
+ return !empty($host) ? $host : $url;
700
+ }, $peers);
701
+
702
+ if (in_array($context->clientIp, $allowedHosts, true)) {
703
+ $this->log("Federated peer auto-whitelisted", ['clientIp' => $context->clientIp]);
704
+ return true;
705
+ }
706
+ }
707
+
692
708
  $whitelisted = false;
693
709
  $type = '';
694
710
 
@@ -701,6 +717,15 @@ class FingerprintEngine
701
717
  } elseif ($this->isHostPathInAllowlist($context->getHeader('host'), $context->path)) {
702
718
  $whitelisted = true;
703
719
  $type = 'host_path_allowlist';
720
+ } elseif (StoreManager::getStore()->has("federated-whitelist:ip:{$context->clientIp}")) {
721
+ $whitelisted = true;
722
+ $type = 'federated_whitelist_ip';
723
+ } elseif (($subnet = RequestUtils::getIpSubnet($context->clientIp)) && StoreManager::getStore()->has("federated-whitelist:subnet:{$subnet}")) {
724
+ $whitelisted = true;
725
+ $type = 'federated_whitelist_subnet';
726
+ } elseif (($ua = $context->getHeader('user-agent')) && StoreManager::getStore()->has("federated-whitelist:user_agent:{$ua}")) {
727
+ $whitelisted = true;
728
+ $type = 'federated_whitelist_user_agent';
704
729
  } elseif ($context->graphqlOperation && $this->isGraphqlOperationInAllowlist($context->graphqlOperation['type'], $context->graphqlOperation['name'])) {
705
730
  $whitelisted = true;
706
731
  $type = 'graphql_operation_allowlist';
@@ -2005,6 +2030,56 @@ class FingerprintEngine
2005
2030
  }
2006
2031
  }
2007
2032
 
2033
+ public function broadcastWhitelist(string $entry, string $entryType = 'ip', int $ttl = 86400): void
2034
+ {
2035
+ $peers = $this->securityConfig['federatedPeers'] ?? [];
2036
+ if (empty($peers)) return;
2037
+
2038
+ $cleanEntry = trim($entry);
2039
+ if ($cleanEntry === '*' || $cleanEntry === '0.0.0.0/0' || $cleanEntry === '::/0') {
2040
+ throw new \InvalidArgumentException('Permissive wildcard entries are prohibited');
2041
+ }
2042
+ if ($cleanEntry === '') {
2043
+ return;
2044
+ }
2045
+
2046
+ $boundedTtl = min(604800, max(60, $ttl));
2047
+ $ts = (int)(microtime(true) * 1000);
2048
+ $msg = "{$ts}:whitelist:{$entryType}:{$cleanEntry}";
2049
+
2050
+ $privateKey = Env::get('ED25519_PRIVATE_KEY');
2051
+ if (!$privateKey) {
2052
+ throw new \RuntimeException('ED25519_PRIVATE_KEY is required for federated whitelist broadcast');
2053
+ }
2054
+
2055
+ $signature = '';
2056
+ try {
2057
+ $cleanKey = str_replace('\n', "\n", $privateKey);
2058
+ $pkeyObj = openssl_pkey_get_private($cleanKey);
2059
+ if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
2060
+ $signature = bin2hex($sigBytes);
2061
+ }
2062
+ } catch (\Throwable $e) {
2063
+ return;
2064
+ }
2065
+ if (empty($signature)) return;
2066
+
2067
+ foreach ($peers as $peerUrl) {
2068
+ $this->asyncPost($peerUrl . '?coop_op=share_whitelist', [
2069
+ 'entry' => $cleanEntry,
2070
+ 'entry_type' => $entryType,
2071
+ 'signature_ed25519' => $signature,
2072
+ 'timestamp' => $ts,
2073
+ 'ttl' => $boundedTtl,
2074
+ ]);
2075
+ }
2076
+ }
2077
+
2078
+ public function broadcastWhitelistedEntry(string $entry, string $entryType = 'ip', int $ttl = 86400): void
2079
+ {
2080
+ $this->broadcastWhitelist($entry, $entryType, $ttl);
2081
+ }
2082
+
2008
2083
  /**
2009
2084
  * Sends an asynchronous HTTP POST request to a given URL with the given
2010
2085
  * parameters (JSON-encoded). Uses non-blocking stream sockets with a short
@@ -316,6 +316,7 @@ class RequestUtils
316
316
 
317
317
  $claimedBrowserInfo = self::parseUserAgent($ua);
318
318
  $claimedBrowser = $claimedBrowserInfo['browser'] ?? null;
319
+ $tlsSpoofingScore = 0.0;
319
320
 
320
321
  if (empty($claimedBrowser) || empty($ua)) {
321
322
  return ['tlsSpoofingScore' => 0.0];
@@ -367,7 +368,7 @@ class RequestUtils
367
368
  $echExpected = $context->getHeader('x-ech-expected') === 'true' || !empty($outerSni);
368
369
 
369
370
  if (!empty($outerSni) && !empty($host) && strcasecmp($outerSni, $host) !== 0 && !$hasEch) {
370
- return ['tlsSpoofingScore' => max($tlsSpoofingScore ?? 0.0, 60.0)];
371
+ return ['tlsSpoofingScore' => max($tlsSpoofingScore, 60.0)];
371
372
  }
372
373
 
373
374
  $browserVer = (int)($claimedBrowserInfo['version'] ?? 0);
@@ -376,7 +377,7 @@ class RequestUtils
376
377
  ($claimedBrowser === 'Firefox' && $browserVer >= 118)
377
378
  );
378
379
  if ($context->isHttps && $isModernEchBrowser && $echExpected && !$hasEch) {
379
- return ['tlsSpoofingScore' => max($tlsSpoofingScore ?? 0.0, 55.0)];
380
+ return ['tlsSpoofingScore' => max($tlsSpoofingScore, 55.0)];
380
381
  }
381
382
 
382
383
  return ['tlsSpoofingScore' => 0.0];