@anonympins/fingerprint 0.3.5 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anonympins/fingerprint",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Advanced anti-bot library for Node.js using multi-layer fingerprinting (JA3, client-side, headers), behavioral analysis, and adaptive Proof-of-Work (PoW) challenges to mitigate scraping, scalping, and automated threats.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -769,6 +769,8 @@ export const verifyPoWAndGenerateTicket = (
769
769
  nonce,
770
770
  solution,
771
771
  difficulty = 4,
772
+ deviceId = '',
773
+ deviceHash = ''
772
774
  ) => {
773
775
  // 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
774
776
  const hash = crypto
@@ -784,10 +786,10 @@ export const verifyPoWAndGenerateTicket = (
784
786
  const expiry = Date.now() + 3600000; // 1 heure
785
787
  const signature = crypto
786
788
  .createHmac("sha256", getPowSecret())
787
- .update(`${ip}:${expiry}`)
789
+ .update(`${expiry}:${ip}:${deviceId}:${deviceHash}`)
788
790
  .digest("hex");
789
791
 
790
- return `${expiry}:${signature}`;
792
+ return `${expiry}|${ip}|${signature}`;
791
793
  };
792
794
 
793
795
 
@@ -822,23 +824,61 @@ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret =
822
824
  }
823
825
  return finalHash === parseInt(solution, 10);
824
826
  };
825
- export const isTicketValid = (ip, ticket) => {
827
+ export const isTicketValid = (ip, ticket, deviceId = '', deviceHash = '') => {
826
828
  // Input validation: ensure the ticket is a non-empty string with the correct format.
827
- if (typeof ticket !== 'string' || !ticket.includes(':')) return false;
828
- const [expiry, sig] = ticket.split(':');
829
+ if (typeof ticket !== 'string') return false;
830
+
831
+ let expiry, originalIp, sig;
832
+ if (ticket.includes('|')) {
833
+ const parts = ticket.split('|');
834
+ if (parts.length < 3) return false;
835
+ [expiry, originalIp, sig] = parts;
836
+ } else if (ticket.includes(':')) {
837
+ // Legacy fallback format
838
+ const parts = ticket.split(':');
839
+ if (parts.length < 2) return false;
840
+ [expiry, sig] = parts;
841
+ originalIp = ip;
842
+ } else {
843
+ return false;
844
+ }
845
+
829
846
  if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
830
- const expectedSig = crypto
831
- .createHmac("sha256", getPowSecret())
832
- .update(`${ip}:${expiry}`)
833
- .digest("hex");
847
+
848
+ let expectedSig;
849
+ if (ticket.includes('|')) {
850
+ expectedSig = crypto
851
+ .createHmac("sha256", getPowSecret())
852
+ .update(`${expiry}:${originalIp}:${deviceId}:${deviceHash}`)
853
+ .digest("hex");
854
+ } else {
855
+ // Legacy expected signature
856
+ expectedSig = crypto
857
+ .createHmac("sha256", getPowSecret())
858
+ .update(`${ip}:${expiry}`)
859
+ .digest("hex");
860
+ }
834
861
 
835
862
  // Use timingSafeEqual to prevent timing attacks
836
863
  try {
837
- return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
864
+ const isSigValid = crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
865
+ if (!isSigValid) return false;
838
866
  } catch (e) {
839
- // This can happen if the buffers have different lengths, which is a failure case.
840
867
  return false;
841
868
  }
869
+
870
+ if (!ticket.includes('|')) {
871
+ return ip === originalIp;
872
+ }
873
+
874
+ // Roaming & Terminal Identity checks:
875
+ if (ip === originalIp) return true;
876
+ const currentSubnet = getIpSubnet(ip);
877
+ const originalSubnet = getIpSubnet(originalIp);
878
+ if (currentSubnet && originalSubnet && currentSubnet === originalSubnet) return true;
879
+
880
+ // Perfect terminal identity matched via HMAC signature
881
+ return !!(deviceId && deviceHash);
842
882
  };
843
883
 
844
884
 
@@ -1778,6 +1818,22 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1778
1818
  }
1779
1819
  }
1780
1820
 
1821
+ // Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
1822
+ let enumerationScore = 0;
1823
+ if (history.length >= 3) {
1824
+ const templates = history.map(h => h.path.replace(/\d+/g, '{num}'));
1825
+ const uniquePaths = new Set(history.map(h => h.path));
1826
+
1827
+ const templateCounts = {};
1828
+ templates.forEach(t => templateCounts[t] = (templateCounts[t] || 0) + 1);
1829
+
1830
+ const maxTemplateRepetition = Math.max(...Object.values(templateCounts), 0);
1831
+ // Si une même structure de route est répétée mais sur des URLs réelles différentes
1832
+ if (maxTemplateRepetition >= 3 && uniquePaths.size === history.length) {
1833
+ enumerationScore = patternWeight * 0.8; // Appliquer une forte pénalité
1834
+ }
1835
+ }
1836
+
1781
1837
  // Garder l'historique à une taille raisonnable
1782
1838
  if (history.length > historySize) {
1783
1839
  history.shift();
@@ -1796,7 +1852,7 @@ function getRequestPatternScore(context, deviceData, patternConfig = {}) {
1796
1852
  }
1797
1853
  newPatternScore = Math.max(0, newPatternScore);
1798
1854
 
1799
- deviceData.lastPatternScore = newPatternScore + instantScore;
1855
+ deviceData.lastPatternScore = newPatternScore + instantScore + enumerationScore;
1800
1856
 
1801
1857
  return { requestPatternScore: Math.min(100, deviceData.lastPatternScore) };
1802
1858
  }
@@ -2341,6 +2397,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
2341
2397
  nonce,
2342
2398
  solution,
2343
2399
  challengeContext = {}, // Le contexte complet du challenge est maintenant passé
2400
+ deviceId = '',
2401
+ deviceHash = ''
2344
2402
  ) {
2345
2403
  const { cpuTarget, baseBlock } = challengeContext;
2346
2404
  if (!cpuTarget || !baseBlock) {
@@ -2392,9 +2450,9 @@ export function verifyCpuTargetPoWAndGenerateTicket(
2392
2450
  const expiry = Date.now() + (ticketTtl || 3600000); // Calcule l'expiration à partir du TTL
2393
2451
  const signature = crypto
2394
2452
  .createHmac("sha256", getPowSecret())
2395
- .update(`${clientIp}:${expiry}`)
2453
+ .update(`${expiry}:${clientIp}:${deviceId}:${deviceHash}`)
2396
2454
  .digest("hex");
2397
- return `${expiry}:${signature}`;
2455
+ return `${expiry}|${clientIp}|${signature}`;
2398
2456
  }
2399
2457
 
2400
2458
  return null;
@@ -2474,7 +2532,7 @@ export class FingerprintEngine {
2474
2532
  console.log(`[FingerprintEngine] ${message}`, data);
2475
2533
  }
2476
2534
  }
2477
- calculateFinalScore = function(suspicionVector) {
2535
+ calculateFinalScore(suspicionVector) {
2478
2536
  const { weights } = this.securityConfig;
2479
2537
  if (!weights) return 0;
2480
2538
 
@@ -2760,6 +2818,11 @@ export class FingerprintEngine {
2760
2818
  return { action: 'next', score: 0, vector: {} };
2761
2819
  }
2762
2820
 
2821
+ // Resolve identity and check for persisted "condemned" status early.
2822
+ const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
2823
+ const currentDeviceHash = getCompositeDeviceHash(requestContext);
2824
+ const isNewDevice = !!newCookie;
2825
+
2763
2826
  // 1. Check static IP allowlist first for maximum performance.
2764
2827
  if (this._isIpInAllowlist(clientIp)) {
2765
2828
  this._log('IP in allowlist - allowing request', { clientIp });
@@ -2798,7 +2861,7 @@ export class FingerprintEngine {
2798
2861
  // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
2799
2862
  if (pow_nonce) {
2800
2863
  const powCookie = cookies?.pow_clearance;
2801
- if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
2864
+ if (!isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash)) { // Only check if there's no valid ticket
2802
2865
  // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
2803
2866
  // The final decision is made later, after calculating the score.
2804
2867
  }
@@ -2810,10 +2873,6 @@ export class FingerprintEngine {
2810
2873
  return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
2811
2874
  }
2812
2875
 
2813
- // Resolve identity and check for persisted "condemned" status early.
2814
- const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
2815
- const isNewDevice = !!newCookie;
2816
-
2817
2876
  this._log('Identity resolved', { deviceId, isNewDevice, hasDeviceData: !!deviceData });
2818
2877
 
2819
2878
  if (deviceData?.condemned) {
@@ -2908,7 +2967,26 @@ export class FingerprintEngine {
2908
2967
  });
2909
2968
 
2910
2969
  let isValid = false;
2911
- const challengeContext = await store.get(`secret:${pow_nonce}`);
2970
+ let challengeContext = await store.get(`secret:${pow_nonce}`);
2971
+
2972
+ // SECURITY: Verify that the retrieved context has not been tampered with
2973
+ if (challengeContext && challengeContext.signature) {
2974
+ const payloadToSign = `${challengeContext.clientSecret}:${challengeContext.cpuTarget}:${challengeContext.fingerprint}:${challengeContext.memDifficulty}:${challengeContext.originalPath}:${clientIp}`;
2975
+ const expectedSignature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
2976
+ try {
2977
+ const isSignatureValid = crypto.timingSafeEqual(
2978
+ Buffer.from(challengeContext.signature, 'hex'),
2979
+ Buffer.from(expectedSignature, 'hex')
2980
+ );
2981
+ if (!isSignatureValid) {
2982
+ this._log('Challenge context signature invalid - storage tampering detected!', { nonce: pow_nonce });
2983
+ challengeContext = null; // Invalidate context immediately
2984
+ }
2985
+ } catch (e) {
2986
+ this._log('Error validating challenge context signature:', e);
2987
+ challengeContext = null;
2988
+ }
2989
+ }
2912
2990
  let ticket = null;
2913
2991
  // Déclarer optimalTtl ici avec une valeur par défaut
2914
2992
  let optimalTtl = this.securityConfig.ticketMaxAge || 3600000;
@@ -2957,11 +3035,12 @@ export class FingerprintEngine {
2957
3035
 
2958
3036
  if ((pow_type === "cpu_target" || !pow_type) && (pow_solution_cpu || pow_solution)) { // !pow_type pour compatibilité
2959
3037
  const cpuSolution = pow_solution_cpu || pow_solution;
2960
- ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext);
3038
+ ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, cpuSolution, challengeContext, deviceId, currentDeviceHash);
2961
3039
  isValid = ticket !== null;
2962
3040
  this._log('CPU target challenge verification', { isValid });
2963
- } else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) { const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext);
2964
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
3041
+ } else if (pow_type === "cpu_mem" && pow_solution_cpu && pow_solution_mem) {
3042
+ const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, finalTtl, pow_nonce, pow_solution_cpu, challengeContext, deviceId, currentDeviceHash);
3043
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret); // Memory PoW is independent of fingerprint
2965
3044
  isValid = cpuTicket !== null && isMemValid;
2966
3045
  if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
2967
3046
  this._log('Combined CPU+Memory challenge verification', {
@@ -3087,7 +3166,10 @@ export class FingerprintEngine {
3087
3166
  if (challengeContext) {
3088
3167
  try {
3089
3168
  const workResult = JSON.parse(pow_solution_work_result);
3090
- getProblemManager(this.securityConfig.usefulWorkConfigPath).integrateSolution(pow_problem_id, workResult);
3169
+ getProblemManager({
3170
+ configPath: this.securityConfig.usefulWorkConfigPath,
3171
+ config: this.securityConfig.usefulWorkConfig
3172
+ }, store).integrateSolution(pow_problem_id, workResult);
3091
3173
 
3092
3174
  await store.delete(`secret:${pow_nonce}`);
3093
3175
  // Accorder un ticket de passage comme pour un PoW normal
@@ -3153,7 +3235,7 @@ export class FingerprintEngine {
3153
3235
  // 1. La requête est suspecte ET il n'y a pas de ticket valide.
3154
3236
  // OU
3155
3237
  // 2. La requête est *très* suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
3156
- const hasValidTicket = isTicketValid(clientIp, powCookie);
3238
+ const hasValidTicket = isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash);
3157
3239
  const mustReChallenge = isSuspiciousHigh && hasValidTicket;
3158
3240
 
3159
3241
  if (isSuspicious && (!hasValidTicket || mustReChallenge)) {
@@ -3197,7 +3279,10 @@ export class FingerprintEngine {
3197
3279
  if (isSuspicious && shouldUseUsefulWork) {
3198
3280
  this._log('Issuing a useful work challenge', { finalScore });
3199
3281
 
3200
- const { problemId, task } = getProblemManager(this.securityConfig.usefulWorkConfigPath).dispatchWork(suspicionFactor);
3282
+ const { problemId, task } = getProblemManager({
3283
+ configPath: this.securityConfig.usefulWorkConfigPath,
3284
+ config: this.securityConfig.usefulWorkConfig
3285
+ }, store).dispatchWork(suspicionFactor);
3201
3286
 
3202
3287
  await store.set(`secret:${nonce}`, { clientSecret, originalPath: path }, 300);
3203
3288
 
@@ -3245,6 +3330,11 @@ export class FingerprintEngine {
3245
3330
 
3246
3331
  // Store the entire challenge context with a short TTL (e.g., 5 minutes)
3247
3332
  const baseBlock = createCpuChallengeBaseBlock(nonce, clientSecret, originalFingerprint);
3333
+
3334
+ // SECURITY: Cryptographically sign the payload before storing it to prevent database tampering
3335
+ const payloadToSign = `${clientSecret}:${cpuChallengeDetails.target}:${originalFingerprint}:${memDifficulty}:${path}:${clientIp}`;
3336
+ const signature = crypto.createHmac("sha256", getPowSecret()).update(payloadToSign).digest("hex");
3337
+
3248
3338
  await store.set(`secret:${nonce}`, {
3249
3339
  clientSecret,
3250
3340
  cpuTarget: cpuChallengeDetails.target,
@@ -3253,6 +3343,7 @@ export class FingerprintEngine {
3253
3343
  memDifficulty: memDifficulty,
3254
3344
  baseBlock: baseBlock, // *** NOUVEAU: Le bloc de base est stocké pour la vérification ***
3255
3345
  originalPath: path, // *** FIX: Store the original path ***
3346
+ signature, // *** NOUVEAU: Cryptographic signature to prevent storage tampering ***
3256
3347
  }, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
3257
3348
 
3258
3349
  // Associate the current challenge nonce with the device for trap URL verification later.
@@ -3302,7 +3393,7 @@ export class FingerprintEngine {
3302
3393
  }
3303
3394
 
3304
3395
  // Basic log for each non-static request that passed without a challenge
3305
- this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie) });
3396
+ this._log('Request passed - no challenge required', { finalScore, hasValidTicket: isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash) });
3306
3397
 
3307
3398
  if (logger) {
3308
3399
  logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now(), vector: suspicionVector });
@@ -3751,6 +3842,7 @@ export const __internal = {
3751
3842
  getTimeInconsistencyScore,
3752
3843
  getClickVarianceScore, // NOUVEAU: Expose pour les tests
3753
3844
  getTlsFingerprint, // NOUVEAU: Expose pour les tests
3845
+ sanitizeTrafficData, // NOUVEAU: Expose pour l'auto-tuner/tests
3754
3846
  getTlsSpoofingScore, // NOUVEAU: Expose pour les tests
3755
3847
  parseJa3,
3756
3848
  generateCpuTargetChallengePage,
@@ -3769,25 +3861,60 @@ export const __internal = {
3769
3861
  let autoTuningJobId = null;
3770
3862
  let lastBestSolution = null; // NOUVEAU: Stocke la meilleure solution trouvée
3771
3863
 
3864
+ /**
3865
+ * Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
3866
+ * Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
3867
+ * @export
3868
+ * @param {Array<object>} trafficData
3869
+ * @returns {Array<object>}
3870
+ */
3871
+ export function sanitizeTrafficData(trafficData) {
3872
+ if (!trafficData || trafficData.length === 0) {
3873
+ return [];
3874
+ }
3875
+ const tempSanitized = [];
3876
+ const deviceCounts = new Map();
3877
+ const maxLogsPerDevice = Math.max(3, Math.floor(trafficData.length * 0.02)); // Max 2% contribution per device
3878
+
3879
+ for (const log of trafficData) {
3880
+ const devId = log.deviceId || 'anonymous';
3881
+ const currentCount = deviceCounts.get(devId) || 0;
3882
+ if (currentCount < maxLogsPerDevice) {
3883
+ deviceCounts.set(devId, currentCount + 1);
3884
+ tempSanitized.push(log);
3885
+ }
3886
+ }
3887
+
3888
+ const passedLogs = tempSanitized.filter(log => log.type === 'request_passed');
3889
+ const suspiciousLogs = tempSanitized.filter(log => log.type !== 'request_passed');
3890
+
3891
+ const minDataPoints = 200; // Seuil par défaut
3892
+ const maxPassedAllowed = Math.max(minDataPoints, suspiciousLogs.length * 9);
3893
+ const shuffledPassed = passedLogs.sort(() => 0.5 - Math.random());
3894
+ const selectedPassed = shuffledPassed.slice(0, maxPassedAllowed);
3895
+
3896
+ return [...suspiciousLogs, ...selectedPassed];
3897
+ }
3898
+
3772
3899
  /**
3773
3900
  * Executes a threshold optimization pass using collected traffic data.
3774
3901
  * @private
3775
- * @param {object} securityConfig - The security configuration object to update.
3776
- * @param {Array<object>} trafficData - The array containing traffic logs.
3777
- * @param {number} minDataPoints - The minimum number of data points required to start optimization.
3778
- * @param {number} maxDataPoints - The maximum number of data points to keep after an optimization cycle.
3779
- * @param {string} [savePath] - Optional path to save the best configuration to a file.
3780
3902
  */
3781
3903
  function runThresholdOptimization(securityConfig, trafficData, minDataPoints, maxDataPoints, savePath) {
3782
- const highConfidenceLogs = trafficData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
3783
- const highConfidenceRatio = trafficData.length > 0 ? highConfidenceLogs / trafficData.length : 0;
3904
+ const sanitizedData = sanitizeTrafficData(trafficData);
3905
+
3906
+ const highConfidenceLogs = sanitizedData.filter(log => log.type === 'challenge_solved' || log.type === 'trap_triggered').length;
3907
+ const highConfidenceRatio = sanitizedData.length > 0 ? highConfidenceLogs / sanitizedData.length : 0;
3784
3908
  const MIN_CONFIDENCE_RATIO = 0.05; // Exiger au moins 5% de signaux forts.
3909
+ const MIN_HIGH_CONFIDENCE_COUNT = 10; // Absolu de secours pour éviter le gel lors de floods
3910
+
3911
+ const hasEnoughSignal = highConfidenceRatio >= MIN_CONFIDENCE_RATIO || highConfidenceLogs >= MIN_HIGH_CONFIDENCE_COUNT;
3785
3912
 
3786
- if (trafficData.length < minDataPoints || highConfidenceRatio < MIN_CONFIDENCE_RATIO) {
3787
- if (trafficData.length < minDataPoints) {
3788
- console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
3913
+ if (sanitizedData.length < minDataPoints || !hasEnoughSignal) {
3914
+ if (sanitizedData.length < minDataPoints) {
3915
+ console.log(`[AutoTuning] Reporté : ${sanitizedData.length}/${minDataPoints} points de données.`);
3789
3916
  } else {
3790
- console.log(`[AutoTuning] Reporté : Ratio de confiance insuffisant (${(highConfidenceRatio * 100).toFixed(2)}% < ${(MIN_CONFIDENCE_RATIO * 100).toFixed(2)}%).`);
3917
+ console.log(`[AutoTuning] Reporté : Signaux de confiance insuffisants (Ratio: ${(highConfidenceRatio * 100).toFixed(2)}% < ${(MIN_CONFIDENCE_RATIO * 100).toFixed(2)}% et absolu: ${highConfidenceLogs} < ${MIN_HIGH_CONFIDENCE_COUNT}).`);
3791
3918
  }
3792
3919
  return;
3793
3920
  }
@@ -3797,9 +3924,9 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints, ma
3797
3924
  trafficData.splice(0, trafficData.length - maxDataPoints);
3798
3925
  }
3799
3926
 
3800
- console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${trafficData.length} points de données.`);
3927
+ console.log(`[AutoTuning] Démarrage du cycle d'optimisation complet avec ${sanitizedData.length} points de données assainis.`);
3801
3928
 
3802
- const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData });
3929
+ const paretoFront = Optimization.Operators.solveFullSecurityTuning({ trafficData: sanitizedData });
3803
3930
 
3804
3931
  if (!paretoFront || paretoFront.length === 0) {
3805
3932
  console.warn("[AutoTuning] L'optimisation n'a retourné aucune solution.");
@@ -78,4 +78,69 @@ class RequestUtilsTest extends TestCase
78
78
  $result = RequestUtils::getClickVarianceScore($context);
79
79
  $this->assertEquals(0.0, $result['clickVarianceScore']);
80
80
  }
81
+
82
+ public function testSanitizeTrafficDataFiltersSybilAttacks(): void
83
+ {
84
+ $trafficData = [];
85
+ // Ajout de 100 logs provenant d'un attaquant (Sybil)
86
+ for ($i = 0; $i < 100; $i++) {
87
+ $trafficData[] = ['deviceId' => 'attacker_device', 'type' => 'trap_triggered'];
88
+ }
89
+ // Ajout de 10 logs d'utilisateurs légitimes distincts
90
+ for ($i = 0; $i < 10; $i++) {
91
+ $trafficData[] = ['deviceId' => "legit_device_{$i}", 'type' => 'challenge_solved'];
92
+ }
93
+
94
+ $sanitized = RequestUtils::sanitizeTrafficData($trafficData);
95
+
96
+ $attackerLogs = array_filter($sanitized, fn($log) => $log['deviceId'] === 'attacker_device');
97
+
98
+ // Total de 110 logs. 2% de 110 est 2.2 -> max(3, 2) = 3 logs maximum autorisés pour l'attaquant.
99
+ $this->assertLessThanOrEqual(3, count($attackerLogs));
100
+ }
101
+
102
+ public function testChallengePayloadSigningAndVerification(): void
103
+ {
104
+ $secret = 'test-fallback-dev-secret-32-chars-minimum';
105
+ $clientIp = '203.0.113.42';
106
+ $payload = [
107
+ 'clientSecret' => 'some_client_secret',
108
+ 'cpuTarget' => '00000000ffffffff',
109
+ 'fingerprint' => 'os:hash|gpu:hash2',
110
+ 'memDifficulty' => '16',
111
+ 'originalPath' => '/submit',
112
+ ];
113
+
114
+ // 1. Cas nominal : Signature et vérification réussies
115
+ $signature = RequestUtils::signChallengePayload($secret, $payload, $clientIp);
116
+ $this->assertNotEmpty($signature);
117
+
118
+ $payloadWithSig = $payload;
119
+ $payloadWithSig['signature'] = $signature;
120
+
121
+ $isValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, $clientIp);
122
+ $this->assertTrue($isValid, "La signature valide doit être acceptée.");
123
+
124
+ // 2. Détection de modification (tampering) sur cpuTarget
125
+ $tamperedPayload = $payloadWithSig;
126
+ $tamperedPayload['cpuTarget'] = 'ffffffffffffffff'; // Tentative de baisse de la difficulté
127
+
128
+ $isTamperedValid = RequestUtils::verifyChallengePayload($secret, $tamperedPayload, $clientIp);
129
+ $this->assertFalse($isTamperedValid, "Un payload modifié doit être rejeté.");
130
+
131
+ // 3. Détection de modification sur le fingerprint
132
+ $tamperedFpPayload = $payloadWithSig;
133
+ $tamperedFpPayload['fingerprint'] = 'os:another_hash|gpu:hash2';
134
+
135
+ $isTamperedFpValid = RequestUtils::verifyChallengePayload($secret, $tamperedFpPayload, $clientIp);
136
+ $this->assertFalse($isTamperedFpValid, "Un fingerprint modifié doit être rejeté.");
137
+
138
+ // 4. Détection d'usurpation d'adresse IP (IP mismatch)
139
+ $isIpMismatchValid = RequestUtils::verifyChallengePayload($secret, $payloadWithSig, '198.51.100.1');
140
+ $this->assertFalse($isIpMismatchValid, "Le payload ne doit pas être valide pour une autre adresse IP.");
141
+
142
+ // 5. Absence de signature
143
+ $isMissingSigValid = RequestUtils::verifyChallengePayload($secret, $payload, $clientIp);
144
+ $this->assertFalse($isMissingSigValid, "Un payload sans signature doit être rejeté.");
145
+ }
81
146
  }
@@ -631,6 +631,25 @@ class RequestUtils
631
631
  }
632
632
  }
633
633
 
634
+ // Détection d'énumération de chemins (crawling/scraping de ressources séquentielles)
635
+ $enumerationScore = 0;
636
+ if (count($history) >= 3) {
637
+ $templates = array_map(function($h) {
638
+ return preg_replace('/\d+/', '{num}', $h['path']);
639
+ }, $history);
640
+
641
+ $uniquePaths = array_unique(array_map(function($h) {
642
+ return $h['path'];
643
+ }, $history));
644
+
645
+ $templateCounts = array_count_values($templates);
646
+ $maxTemplateRepetition = !empty($templateCounts) ? max($templateCounts) : 0;
647
+
648
+ if ($maxTemplateRepetition >= 3 && count($uniquePaths) === count($history)) {
649
+ $enumerationScore = $patternWeight * 0.8;
650
+ }
651
+ }
652
+
634
653
  // Logique de décroissance et de score final
635
654
  $newPatternScore = $deviceData['lastPatternScore'] ?? 0;
636
655
 
@@ -641,7 +660,7 @@ class RequestUtils
641
660
  }
642
661
  $newPatternScore = max(0, $newPatternScore);
643
662
 
644
- $deviceData['lastPatternScore'] = $newPatternScore + $instantScore;
663
+ $deviceData['lastPatternScore'] = $newPatternScore + $instantScore + $enumerationScore;
645
664
 
646
665
  return ['requestPatternScore' => min(100.0, $deviceData['lastPatternScore'])];
647
666
  }
@@ -970,4 +989,155 @@ class RequestUtils
970
989
  $newScore = min(100.0, max(0.0, $current + $change));
971
990
  $store->set($key, ['score' => $newScore, 'lastUpdate' => time()], 86400 * 7); // TTL de 7 jours
972
991
  }
992
+
993
+
994
+ /**
995
+ * Assainit les données de trafic pour l'auto-tuner afin de prévenir les attaques par empoisonnement.
996
+ * Limite la contribution de chaque deviceId à un pourcentage maximum (ex: 2%) du jeu de données total.
997
+ *
998
+ * @param array<int, array<string, mixed>> $trafficData
999
+ * @return array<int, array<string, mixed>>
1000
+ */
1001
+ public static function sanitizeTrafficData(array $trafficData): array
1002
+ {
1003
+ if (empty($trafficData)) {
1004
+ return [];
1005
+ }
1006
+
1007
+ $tempSanitized = [];
1008
+ $deviceCounts = [];
1009
+ $maxLogsPerDevice = max(3, (int)floor(count($trafficData) * 0.02));
1010
+
1011
+ foreach ($trafficData as $log) {
1012
+ $deviceId = $log['deviceId'] ?? 'anonymous';
1013
+ if (!isset($deviceCounts[$deviceId])) {
1014
+ $deviceCounts[$deviceId] = 0;
1015
+ }
1016
+ if ($deviceCounts[$deviceId] < $maxLogsPerDevice) {
1017
+ $deviceCounts[$deviceId]++;
1018
+ $tempSanitized[] = $log;
1019
+ }
1020
+ }
1021
+
1022
+ $passedLogs = [];
1023
+ $suspiciousLogs = [];
1024
+ foreach ($tempSanitized as $log) {
1025
+ if (($log['type'] ?? '') === 'request_passed') {
1026
+ $passedLogs[] = $log;
1027
+ } else {
1028
+ $suspiciousLogs[] = $log;
1029
+ }
1030
+ }
1031
+
1032
+ $minDataPoints = 200; // Seuil par défaut
1033
+ $maxPassedAllowed = max($minDataPoints, count($suspiciousLogs) * 9);
1034
+
1035
+ if (count($passedLogs) > $maxPassedAllowed) {
1036
+ shuffle($passedLogs);
1037
+ $passedLogs = array_slice($passedLogs, 0, $maxPassedAllowed);
1038
+ }
1039
+
1040
+ return array_merge($suspiciousLogs, $passedLogs);
1041
+ }
1042
+
1043
+ /**
1044
+ * Génère une signature HMAC-SHA256 pour sécuriser les données du challenge stockées.
1045
+ * @param string $secret Le secret global (POW_SECRET).
1046
+ * @param array<string, mixed> $payload Les données du challenge.
1047
+ * @param string $clientIp L'IP du client pour lier la signature.
1048
+ * @return string
1049
+ */
1050
+ public static function signChallengePayload(string $secret, array $payload, string $clientIp): string
1051
+ {
1052
+ $dataToSign = implode(':', [
1053
+ $payload['clientSecret'] ?? '',
1054
+ $payload['cpuTarget'] ?? '',
1055
+ $payload['fingerprint'] ?? '',
1056
+ $payload['memDifficulty'] ?? '',
1057
+ $payload['originalPath'] ?? '',
1058
+ $clientIp
1059
+ ]);
1060
+
1061
+ return hash_hmac('sha256', $dataToSign, $secret);
1062
+ }
1063
+
1064
+ /**
1065
+ * Vérifie la signature HMAC-SHA256 des données de challenge récupérées du store.
1066
+ * @param string $secret Le secret global (POW_SECRET).
1067
+ * @param array<string, mixed> $payload Les données du challenge contenant la signature.
1068
+ * @param string $clientIp L'IP du client.
1069
+ * @return bool True si la signature est valide, false sinon.
1070
+ */
1071
+ public static function verifyChallengePayload(string $secret, array $payload, string $clientIp): bool
1072
+ {
1073
+ if (empty($payload['signature'])) {
1074
+ return false;
1075
+ }
1076
+
1077
+ $storedSignature = $payload['signature'];
1078
+ $payloadWithoutSig = $payload;
1079
+ unset($payloadWithoutSig['signature']);
1080
+
1081
+ $expectedSignature = self::signChallengePayload($secret, $payloadWithoutSig, $clientIp);
1082
+
1083
+ return hash_equals($expectedSignature, $storedSignature);
1084
+ }
1085
+
1086
+ /**
1087
+ * Vérifie si un ticket de clearance (PoW) est valide, en supportant la tolérance au roaming.
1088
+ *
1089
+ * @param string $ip L'adresse IP de la requête courante.
1090
+ * @param string|null $ticket Le ticket de clearance extrait du cookie.
1091
+ * @param string $deviceId L'identifiant du cookie de l'appareil.
1092
+ * @param string $deviceHash L'empreinte matérielle calculée côté serveur.
1093
+ * @param string $secret La clé secrète (POW_SECRET).
1094
+ * @return bool True si le ticket est valide et correspond aux contraintes de sécurité.
1095
+ */
1096
+ public static function isTicketValid(string $ip, ?string $ticket, string $deviceId = '', string $deviceHash = '', string $secret = ''): bool
1097
+ {
1098
+ if (empty($ticket)) {
1099
+ return false;
1100
+ }
1101
+
1102
+ if (str_contains($ticket, '|')) {
1103
+ $parts = explode('|', $ticket);
1104
+ if (count($parts) < 3) return false;
1105
+ [$expiry, $originalIp, $sig] = $parts;
1106
+ } elseif (str_contains($ticket, ':')) {
1107
+ // Fallback rétrocompatible pour les anciens tickets
1108
+ $parts = explode(':', $ticket);
1109
+ if (count($parts) < 2) return false;
1110
+ [$expiry, $sig] = $parts;
1111
+ $originalIp = $ip;
1112
+ } else {
1113
+ return false;
1114
+ }
1115
+
1116
+ if (empty($expiry) || empty($sig) || (time() * 1000) > (int)$expiry) {
1117
+ return false;
1118
+ }
1119
+
1120
+ if (str_contains($ticket, '|')) {
1121
+ $expectedSig = hash_hmac('sha256', "{$expiry}:{$originalIp}:{$deviceId}:{$deviceHash}", $secret);
1122
+ } else {
1123
+ $expectedSig = hash_hmac('sha256', "{$ip}:{$expiry}", $secret);
1124
+ }
1125
+
1126
+ if (!hash_equals($expectedSig, $sig)) {
1127
+ return false;
1128
+ }
1129
+
1130
+ if (!str_contains($ticket, '|')) {
1131
+ return $ip === $originalIp;
1132
+ }
1133
+
1134
+ if ($ip === $originalIp) return true;
1135
+ $currentSubnet = self::getIpSubnet($ip);
1136
+ $originalSubnet = self::getIpSubnet($originalIp);
1137
+ if ($currentSubnet !== null && $originalSubnet !== null && $currentSubnet === $originalSubnet) {
1138
+ return true;
1139
+ }
1140
+
1141
+ return !empty($deviceId) && !empty($deviceHash); // Match d'identité matérielle stricte (deviceId + deviceHash validés par HMAC)
1142
+ }
973
1143
  }