@anonympins/fingerprint 0.0.1 → 0.0.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.
Files changed (4) hide show
  1. package/README.md +136 -22
  2. package/fingerprint.js +502 -230
  3. package/library.js +0 -824
  4. package/package.json +7 -3
package/fingerprint.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
2
  import crypto from "node:crypto";
3
+ import { Optimization } from "./library.js";
3
4
 
4
5
  const POW_SECRET = process.env.POW_SECRET;
5
6
 
@@ -9,7 +10,7 @@ if (!POW_SECRET && process.env.NODE_ENV === 'production') {
9
10
  console.warn('Warning: POW_SECRET environment variable not set. Using a default, insecure secret for development.');
10
11
  }
11
12
  /**
12
- * Algorithme de hachage cyrb53 (rapide et faible taux de collision). Exporté pour réutilisation.
13
+ * cyrb53 hash algorithm (fast with a low collision rate). Exported for reuse.
13
14
  */
14
15
  export const cyrb53 = (str, seed = 0) => {
15
16
  let h1 = 0xdeadbeef ^ seed,
@@ -29,8 +30,8 @@ export const cyrb53 = (str, seed = 0) => {
29
30
  };
30
31
 
31
32
  /**
32
- * Classe pour construire une empreinte composite (Multi-Hash).
33
- * Format de sortie : "grp1:hash1|grp2:hash2|grp3:hash3"
33
+ * Class to build a composite fingerprint (Multi-Hash).
34
+ * Output format: "grp1:hash1|grp2:hash2|grp3:hash3"
34
35
  */
35
36
  export class FingerprintBuilder {
36
37
  constructor() {
@@ -38,20 +39,20 @@ export class FingerprintBuilder {
38
39
  }
39
40
 
40
41
  /**
41
- * Ajoute un composant au hash global.
42
- * @param {string} group - Le nom du groupe (ex: 'hw', 'screen', 'geo')
43
- * @param {string|number|boolean} value - La valeur brute à hasher
42
+ * Adds a component to the global hash.
43
+ * @param {string} group - The group name (e.g., 'hw', 'screen', 'geo')
44
+ * @param {string|number|boolean} value - The raw value to be hashed
44
45
  */
45
46
  add(group, value) {
46
47
  if (value === undefined || value === null) return this;
47
- // On hash la valeur individuellement pour l'anonymiser et réduire sa taille
48
+ // Hash the value individually to anonymize it and reduce its size
48
49
  this.components.set(group, cyrb53(String(value)));
49
50
  return this;
50
51
  }
51
52
 
52
53
  /**
53
- * Génère la chaîne de signature finale.
54
- * Trie les clés pour garantir un ordre déterministe.
54
+ * Generates the final signature string.
55
+ * Sorts keys to ensure a deterministic order.
55
56
  */
56
57
  toString() {
57
58
  return Array.from(this.components.entries())
@@ -61,10 +62,10 @@ export class FingerprintBuilder {
61
62
  }
62
63
 
63
64
  /**
64
- * Compare deux empreintes et retourne un score de similarité (0 à 1).
65
- * Utilise des poids pour donner plus d'importance aux invariants forts (Canvas, GPU).
66
- * @param {string} fpString1 - Empreinte A
67
- * @param {string} fpString2 - Empreinte B
65
+ * Compares two fingerprints and returns a similarity score (0 to 1).
66
+ * Uses weights to give more importance to strong invariants (Canvas, GPU).
67
+ * @param {string} fpString1 - Fingerprint A
68
+ * @param {string} fpString2 - Fingerprint B
68
69
  */
69
70
  static compare(fpString1, fpString2) {
70
71
  if (!fpString1 || !fpString2) return 0;
@@ -81,15 +82,15 @@ export class FingerprintBuilder {
81
82
  const map1 = parse(fpString1);
82
83
  const map2 = parse(fpString2);
83
84
 
84
- // Poids de "véracité" (Entropie/Stabilité)
85
+ // "Veracity" weights (Entropy/Stability)
85
86
  const weights = {
86
- cvs: 4.0, // Canvas: Très haute entropie (Rendu unique)
87
- gpu: 3.0, // GPU: Haute entropie (Matériel spécifique)
88
- hw: 1.5, // Hardware: Moyenne entropie
89
- scr: 1.0, // Screen: Moyenne
90
- geo: 0.5, // Geo: Faible (VPN/Voyage)
91
- os: 0.5, // OS: Faible (Générique)
92
- bot: 0.0, // Bot: Informatif
87
+ cvs: 4.0, // Canvas: Very high entropy (Unique rendering)
88
+ gpu: 3.0, // GPU: High entropy (Specific hardware)
89
+ hw: 1.5, // Hardware: Medium entropy
90
+ scr: 1.0, // Screen: Medium
91
+ geo: 0.5, // Geo: Low (VPN/Travel)
92
+ os: 0.5, // OS: Low (Generic)
93
+ bot: 0.0, // Bot: Informational
93
94
  };
94
95
 
95
96
  let weightedMatches = 0;
@@ -112,11 +113,11 @@ export class FingerprintBuilder {
112
113
  }
113
114
  }
114
115
 
115
- // Cache pour éviter de recalculer les constantes (Hardware, etc.)
116
+ // Cache to avoid recalculating constants (Hardware, etc.)
116
117
  let cachedBuilder = null;
117
118
 
118
119
  /**
119
- * Génère l'empreinte de l'appareil actuel.
120
+ * Generates the fingerprint of the current device.
120
121
  */
121
122
  export const getDeviceFingerprint = () => {
122
123
  // NOTE: This is client-side code and should be in a separate file.
@@ -131,29 +132,29 @@ export const getDeviceFingerprint = () => {
131
132
 
132
133
  cachedBuilder = new FingerprintBuilder();
133
134
 
134
- // 1. Hardware (Très stable) : Cœurs, RAM, GPU (si dispo via canvas), Touch
135
+ // 1. Hardware (Very stable): Cores, RAM, GPU (if available via canvas), Touch
135
136
  cachedBuilder.add(
136
137
  "hw",
137
138
  `${nav.hardwareConcurrency}_${nav.deviceMemory}_${nav.maxTouchPoints}`,
138
139
  );
139
140
 
140
- // 2. Geo/Locale (Stable sauf voyage/VPN) : Timezone, Langue
141
+ // 2. Geo/Locale (Stable except for travel/VPN): Timezone, Language
141
142
  cachedBuilder.add(
142
143
  "geo",
143
144
  `${Intl.DateTimeFormat().resolvedOptions().timeZone}_${nav.language}_${new Date().getTimezoneOffset()}`,
144
145
  );
145
146
 
146
- // 3. Screen (Stable sauf changement moniteur/zoom) : Dimensions, ColorDepth
147
- // Note : On utilise availWidth/Height qui exclut la barre des tâches, parfois plus unique
147
+ // 3. Screen (Stable except for monitor/zoom changes): Dimensions, ColorDepth
148
+ // Note: We use availWidth/Height which excludes the taskbar, sometimes more unique
148
149
  cachedBuilder.add(
149
150
  "scr",
150
151
  `${screen.width}x${screen.height}_${screen.colorDepth}`,
151
152
  );
152
153
 
153
- // 4. Platform (Stable) : OS, Engine
154
+ // 4. Platform (Stable): OS, Engine
154
155
  cachedBuilder.add("os", nav.platform);
155
156
 
156
- // 5. Graphics (WebGL Vendor/Renderer) - Invariant matériel fort
157
+ // 5. Graphics (WebGL Vendor/Renderer) - Strong hardware invariant
157
158
  try {
158
159
  const canvas = document.createElement("canvas");
159
160
  const gl =
@@ -168,8 +169,8 @@ export const getDeviceFingerprint = () => {
168
169
  }
169
170
  } catch (e) {}
170
171
 
171
- // 6. Canvas Fingerprinting (Rendering quirks) - Ajoute ~5-10% d'unicité
172
- // Exploite les micro-différences d'anti-aliasing et de rendu des polices
172
+ // 6. Canvas Fingerprinting (Rendering quirks) - Adds ~5-10% uniqueness
173
+ // Exploits micro-differences in anti-aliasing and font rendering
173
174
  try {
174
175
  const canvas = document.createElement("canvas");
175
176
  const ctx = canvas.getContext("2d");
@@ -188,23 +189,23 @@ export const getDeviceFingerprint = () => {
188
189
  }
189
190
  } catch (e) {}
190
191
 
191
- // 7. Bot Detection (Indication cachée)
192
+ // 7. Bot Detection (Hidden indicator)
192
193
  if (nav.webdriver) cachedBuilder.add("bot", "true");
193
194
  }
194
195
 
195
- // On retourne une copie pour permettre d'ajouter des champs dynamiques si besoin sans polluer le cache
196
+ // Return a copy to allow adding dynamic fields if needed without polluting the cache
196
197
  return cachedBuilder.toString();
197
198
  };
198
199
 
199
200
  /**
200
- * Génère une signature de requête incluant le contexte.
201
+ * Generates a request signature including the context.
201
202
  * @param {object} payload
202
203
  */
203
204
  export const generateRequestSignature = (payload = {}) => {
204
205
  const deviceFp = getDeviceFingerprint();
205
206
 
206
- // On crée un builder temporaire qui hérite du deviceFp
207
- // Note: Ici on fait simple, on concatène juste le hash du payload
207
+ // Create a temporary builder that inherits from deviceFp
208
+ // Note: Here we keep it simple, just concatenating the payload hash
208
209
  const sortedPayload = Object.keys(payload)
209
210
  .sort()
210
211
  .map((k) => `${k}=${payload[k]}`)
@@ -215,22 +216,22 @@ export const generateRequestSignature = (payload = {}) => {
215
216
  };
216
217
 
217
218
  /**
218
- * Génère une signature HMAC-SHA256 pour les données de combat.
219
- * @param {object} payload - Les données à signer (ex: { opponentId, victory, damageDealt }).
220
- * @param {string} secret - La clé secrète partagée.
221
- * @returns {Promise<string>} La signature hexadécimale.
219
+ * Generates an HMAC-SHA256 signature for combat data.
220
+ * @param {object} payload - The data to sign (e.g., { opponentId, victory, damageDealt }).
221
+ * @param {string} secret - The shared secret key.
222
+ * @returns {Promise<string>} The hexadecimal signature.
222
223
  */
223
224
  export const generateCombatSignature = async (payload, secret) => {
224
225
  // NOTE: This is client-side code using the Web Crypto API (`window.crypto`).
225
226
  // It should be moved to a client-side script file.
226
227
 
227
- // 1. Créer une chaîne de caractères stable à partir du payload.
228
+ // 1. Create a stable string from the payload.
228
229
  const sortedPayload = Object.keys(payload)
229
230
  .sort()
230
231
  .map((k) => `${k}=${payload[k]}`)
231
232
  .join("&");
232
233
 
233
- // 2. Utiliser l'API Web Crypto pour le HMAC
234
+ // 2. Use the Web Crypto API for HMAC
234
235
  const encoder = new TextEncoder();
235
236
  const key = await window.crypto.subtle.importKey(
236
237
  "raw",
@@ -245,7 +246,7 @@ export const generateCombatSignature = async (payload, secret) => {
245
246
  encoder.encode(sortedPayload),
246
247
  );
247
248
 
248
- // 3. Convertir la signature en chaîne hexadécimale.
249
+ // 3. Convert the signature to a hexadecimal string.
249
250
  const hashArray = Array.from(new Uint8Array(signatureBuffer));
250
251
  const hexString = hashArray
251
252
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -254,13 +255,13 @@ export const generateCombatSignature = async (payload, secret) => {
254
255
  };
255
256
 
256
257
  /**
257
- * Génère le contenu HTML pour un challenge TSP (Traveling Salesperson Problem).
258
- * @param {string} nonce - Nonce unique pour le challenge.
259
- * @param {number} numCities - Nombre de villes à inclure dans le problème.
260
- * @param {number} targetMaxDistance - Distance maximale acceptable pour la solution.
261
- * @param {Array<{x: number, y: number}>} cities - Coordonnées des villes.
262
- * @param {string} path - Chemin de redirection après résolution.
263
- * @returns {string} HTML de la page de challenge.
258
+ * Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
259
+ * @param {string} nonce - Unique nonce for the challenge.
260
+ * @param {number} numCities - Number of cities to include in the problem.
261
+ * @param {number} targetMaxDistance - Maximum acceptable distance for the solution.
262
+ * @param {Array<{x: number, y: number}>} cities - Coordinates of the cities.
263
+ * @param {string} path - Redirect path after solving.
264
+ * @returns {string} HTML of the challenge page.
264
265
  */
265
266
  const generateTspChallenge = (
266
267
  nonce,
@@ -272,28 +273,28 @@ const generateTspChallenge = (
272
273
  const citiesJson = JSON.stringify(cities);
273
274
  return `
274
275
  <html>
275
- <head><title>Vérification de sécurité Avancée (Niveau 3)</title></head>
276
+ <head><title>Advanced Security Check (Level 3)</title></head>
276
277
  <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
277
- <h1>Vérification Ultime (Niveau 3)</h1>
278
- <p>Veuillez résoudre ce petit problème d'optimisation pour prouver que vous êtes humain.</p>
279
- <div id="loader" style="margin:20px;">⚙️ Calcul d'itinéraire en cours... (${numCities} villes)</div>
278
+ <h1>Ultimate Verification (Level 3)</h1>
279
+ <p>Please solve this small optimization problem to prove you are human.</p>
280
+ <div id="loader" style="margin:20px;">⚙️ Calculating route... (${numCities} cities)</div>
280
281
  <script>
281
282
  const cities = ${citiesJson};
282
283
  const nonce = "${nonce}";
283
284
  const targetMaxDistance = ${targetMaxDistance};
284
285
 
285
- // Fonction utilitaire pour calculer la distance entre deux villes
286
+ // Utility function to calculate the distance between two cities
286
287
  function distance(city1, city2) {
287
288
  return Math.sqrt(Math.pow(city1.x - city2.x, 2) + Math.pow(city1.y - city2.y, 2));
288
289
  }
289
290
 
290
- // Fonction utilitaire pour évaluer la distance totale d'un chemin
291
+ // Utility function to evaluate the total distance of a path
291
292
  function evaluatePathDistance(cities, path) {
292
293
  let totalDistance = 0;
293
294
  for (let i = 0; i < path.length - 1; i++) {
294
295
  totalDistance += distance(cities[path[i]], cities[path[i + 1]]);
295
296
  }
296
- totalDistance += distance(cities[path[path.length - 1]], cities[path[0]]); // Retour au départ
297
+ totalDistance += distance(cities[path[path.length - 1]], cities[path[0]]); // Return to start
297
298
  return totalDistance;
298
299
  }
299
300
 
@@ -305,7 +306,7 @@ const generateTspChallenge = (
305
306
  let currentPath = [];
306
307
  let visited = new Array(numCities).fill(false);
307
308
 
308
- let currentCityIndex = 0; // Toujours commencer par la première ville pour la reproductibilité
309
+ let currentCityIndex = 0; // Always start with the first city for reproducibility
309
310
  currentPath.push(currentCityIndex);
310
311
  visited[currentCityIndex] = true;
311
312
 
@@ -330,7 +331,7 @@ const generateTspChallenge = (
330
331
  }
331
332
 
332
333
  async function solve() {
333
- // Pour ne pas freezer le navigateur, on yield le thread de temps en temps
334
+ // To avoid freezing the browser, yield the thread from time to time
334
335
  await new Promise(resolve => setTimeout(resolve, 10));
335
336
  const solutionPath = solveTspNearestNeighbor(cities);
336
337
  const solutionDistance = evaluatePathDistance(cities, solutionPath);
@@ -338,7 +339,7 @@ const generateTspChallenge = (
338
339
  if (solutionDistance <= targetMaxDistance) {
339
340
  window.location.href = "${path}" + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(solutionPath);
340
341
  } else {
341
- document.getElementById('loader').innerText = "Erreur: Impossible de trouver une solution suffisante. Veuillez réessayer.";
342
+ document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
342
343
  }
343
344
  }
344
345
  solve();
@@ -348,13 +349,13 @@ const generateTspChallenge = (
348
349
  };
349
350
 
350
351
  /**
351
- * Vérifie une solution de PoW TSP.
352
- * @param {string} nonce - Nonce du challenge.
353
- * @param {string} solutionPathJson - Chemin proposé par le client (JSON stringifié).
354
- * @param {number} numCities - Nombre de villes du challenge.
355
- * @param {number} targetMaxDistance - Distance maximale acceptable.
356
- * @param {Array<{x: number, y: number}>} cities - Coordonnées des villes.
357
- * @returns {boolean} True si la solution est valide.
352
+ * Verifies a TSP PoW solution.
353
+ * @param {string} nonce - The challenge nonce.
354
+ * @param {string} solutionPathJson - The path proposed by the client (stringified JSON).
355
+ * @param {number} numCities - The number of cities in the challenge.
356
+ * @param {number} targetMaxDistance - The maximum acceptable distance.
357
+ * @param {Array<{x: number, y: number}>} cities - The coordinates of the cities.
358
+ * @returns {boolean} True if the solution is valid.
358
359
  */
359
360
  export const verifyTspChallenge = (
360
361
  nonce,
@@ -368,7 +369,7 @@ export const verifyTspChallenge = (
368
369
  if (!Array.isArray(solutionPath) || solutionPath.length !== numCities)
369
370
  return false;
370
371
 
371
- // Vérifier que le chemin est une permutation valide des villes
372
+ // Verify that the path is a valid permutation of the cities
372
373
  const uniqueCities = new Set(solutionPath);
373
374
  if (
374
375
  uniqueCities.size !== numCities ||
@@ -377,11 +378,11 @@ export const verifyTspChallenge = (
377
378
  )
378
379
  return false;
379
380
 
380
- // Recalculer la distance côté serveur
381
+ // Recalculate the distance on the server side
381
382
  let totalDistance = 0;
382
383
  let totalPenalty = 0;
383
384
 
384
- // Fonction pour calculer l'angle entre 3 points (p1 -> p2 -> p3)
385
+ // Function to calculate the angle between 3 points (p1 -> p2 -> p3)
385
386
  const calculateAngle = (p1, p2, p3) => {
386
387
  const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
387
388
  const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
@@ -398,31 +399,31 @@ export const verifyTspChallenge = (
398
399
  const p2_idx = solutionPath[(i + 1) % numCities];
399
400
  const p3_idx = solutionPath[(i + 2) % numCities];
400
401
 
401
- // 1. Calcul de la distance du segment
402
+ // 1. Calculate segment distance
402
403
  totalDistance += Math.sqrt(Math.pow(cities[p1_idx].x - cities[p2_idx].x, 2) + Math.pow(cities[p1_idx].y - cities[p2_idx].y, 2));
403
404
 
404
- // 2. Calcul de la pénalité de virage
405
+ // 2. Calculate turn penalty
405
406
  const angle = calculateAngle(
406
407
  cities[p1_idx],
407
408
  cities[p2_idx],
408
409
  cities[p3_idx],
409
410
  );
410
411
  if (angle < 45) {
411
- // Pénalité pour les virages très serrés (< 45 degrés)
412
- totalPenalty += (45 - angle) * 5; // La pénalité est proportionnelle à l'acuité de l'angle
412
+ // Penalty for very sharp turns (< 45 degrees)
413
+ totalPenalty += (45 - angle) * 5; // The penalty is proportional to the sharpness of the angle
413
414
  }
414
415
  }
415
416
 
416
417
  const finalScore = totalDistance + totalPenalty;
417
418
  return finalScore <= targetMaxDistance;
418
419
  } catch (e) {
419
- console.error("Erreur lors de la vérification du challenge TSP:", e);
420
+ console.error("Error during TSP challenge verification:", e);
420
421
  return false;
421
422
  }
422
423
  };
423
424
 
424
425
  /**
425
- * Génère le contenu HTML pour le challenge PoW CPU (SHA-256).
426
+ * Generates the HTML content for the CPU PoW challenge (SHA-256).
426
427
  */
427
428
  const generateCpuPoWChallenge = (
428
429
  clientIp,
@@ -432,11 +433,11 @@ const generateCpuPoWChallenge = (
432
433
  ) => {
433
434
  return `
434
435
  <html>
435
- <head><title>Vérification de sécurité</title></head>
436
+ <head><title>Security Check</title></head>
436
437
  <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
437
- <h1>Un instant... (Niveau 1)</h1>
438
- <p>Nous vérifions que vous n'êtes pas un bot. Cela prend quelques secondes.</p>
439
- <div id="loader" style="margin:20px;">⚙️ Calcul de sécurité CPU en cours...</div>
438
+ <h1>One moment... (Level 1)</h1>
439
+ <p>We are verifying that you are not a bot. This takes a few seconds.</p>
440
+ <div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
440
441
  <script>
441
442
  async function solve() {
442
443
  const ip = "${clientIp}";
@@ -451,7 +452,7 @@ const generateCpuPoWChallenge = (
451
452
  const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
452
453
  if (hash.startsWith(target)) break;
453
454
  solution++;
454
- if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // Pour ne pas freezer le navigateur
455
+ if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // To avoid freezing the browser
455
456
  }
456
457
  window.location.href = "${path}" + "?pow_type=cpu&pow_nonce=" + nonce + "&pow_solution=" + solution;
457
458
  }
@@ -463,7 +464,7 @@ const generateCpuPoWChallenge = (
463
464
  };
464
465
 
465
466
  /**
466
- * Génère le contenu HTML pour un challenge PoW gourmand en mémoire.
467
+ * Generates the HTML content for a memory-intensive PoW challenge.
467
468
  */
468
469
  const generateMemoryPoWChallenge = (
469
470
  clientIp,
@@ -471,14 +472,14 @@ const generateMemoryPoWChallenge = (
471
472
  difficulty = 16,
472
473
  path = "",
473
474
  ) => {
474
- // difficulty ici est la taille du buffer en Mo.
475
+ // difficulty here is the buffer size in MB.
475
476
  return `
476
477
  <html>
477
- <head><title>Vérification de sécurité Avancée</title></head>
478
+ <head><title>Advanced Security Check</title></head>
478
479
  <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
479
- <h1>Vérification renforcée... (Niveau 2)</h1>
480
- <p>Votre activité nécessite une vérification de sécurité supplémentaire.</p>
481
- <div id="loader" style="margin:20px;">⚙️ Allocation et calcul mémoire en cours... (${difficulty} Mo)</div>
480
+ <h1>Enhanced Verification... (Level 2)</h1>
481
+ <p>Your activity requires an additional security check.</p>
482
+ <div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
482
483
  <script>
483
484
  async function solve() {
484
485
  const nonce = "${nonce}";
@@ -499,7 +500,7 @@ const generateMemoryPoWChallenge = (
499
500
  }
500
501
  window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
501
502
  } catch(e) {
502
- document.getElementById('loader').innerText = "Erreur: Mémoire insuffisante. Veuillez rafraîchir.";
503
+ document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
503
504
  }
504
505
  }
505
506
  solve();
@@ -509,7 +510,7 @@ const generateMemoryPoWChallenge = (
509
510
  };
510
511
 
511
512
  /**
512
- * Vérifie si une solution PoW est valide et génère un ticket de passage.
513
+ * Verifies if a PoW solution is valid and generates a clearance ticket.
513
514
  */
514
515
  export const verifyPoWAndGenerateTicket = (
515
516
  ip,
@@ -517,7 +518,7 @@ export const verifyPoWAndGenerateTicket = (
517
518
  solution,
518
519
  difficulty = 4,
519
520
  ) => {
520
- // 1. Vérifier la solution : hash(ip + nonce + solution) doit commencer par N zéros
521
+ // 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
521
522
  const hash = crypto
522
523
  .createHash("sha256")
523
524
  .update(`${ip}:${nonce}:${solution}`)
@@ -527,7 +528,7 @@ export const verifyPoWAndGenerateTicket = (
527
528
  return null;
528
529
  }
529
530
 
530
- // 2. Générer un ticket HMAC pour que le client n'ait plus à le refaire pendant 1h
531
+ // 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
531
532
  const expiry = Date.now() + 3600000; // 1 heure
532
533
  const signature = crypto
533
534
  .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
@@ -538,8 +539,8 @@ export const verifyPoWAndGenerateTicket = (
538
539
  };
539
540
 
540
541
  /**
541
- * Vérifie une solution de PoW mémoire.
542
- * Le serveur refait le même calcul pour valider.
542
+ * Verifies a memory PoW solution.
543
+ * The server performs the same calculation to validate.
543
544
  */
544
545
  export const verifyMemoryPoW = (nonce, solution, difficulty = 16) => {
545
546
  const size = difficulty * 1024 * 1024;
@@ -565,15 +566,15 @@ export const isTicketValid = (ip, ticket) => {
565
566
  .update(`${ip}:${expiry}`)
566
567
  .digest("hex");
567
568
 
568
- // Utilisation de timingSafeEqual pour éviter les attaques temporelles
569
+ // Use timingSafeEqual to prevent timing attacks
569
570
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig));
570
571
  };
571
572
 
572
573
  /**
573
- * Crée un hash stable basé sur les caractéristiques de l'appareil, indépendamment de l'IP.
574
- * C'est notre "empreinte de niveau 2".
575
- * @param {object} req - L'objet de la requête Express.
576
- * @returns {string} Un hash représentant l'appareil.
574
+ * Creates a stable hash based on device characteristics, independent of the IP.
575
+ * This is our "level 2 fingerprint".
576
+ * @param {object} req - The Express request object.
577
+ * @returns {string} A hash representing the device.
577
578
  */
578
579
  function getDeviceHash(req) {
579
580
  const srv = new FingerprintBuilder();
@@ -581,33 +582,33 @@ function getDeviceHash(req) {
581
582
  if (req.headers["sec-ch-ua-platform"])
582
583
  srv.add("os", req.headers["sec-ch-ua-platform"]);
583
584
  if (req.headers["sec-ch-ua"]) srv.add("ch", req.headers["sec-ch-ua"]);
584
- return srv.toString(); // Retourne la chaîne de caractères complète de l'empreinte pour une comparaison détaillée.
585
+ return srv.toString(); // Returns the full fingerprint string for detailed comparison.
585
586
  }
586
587
 
587
588
  /**
588
- * Calcule les indicateurs de suspicion liés aux anomalies des headers HTTP.
589
- * @param {object} req - L'objet de la requête Express.
589
+ * Calculates suspicion indicators related to HTTP header anomalies.
590
+ * @param {object} req - The Express request object.
590
591
  * @returns {{headerAnomalyScore: number}}
591
592
  */
592
593
  function getHeaderAnomalies(req, consistencyScore) {
593
594
  // FIX: consistencyScore est maintenant passé
594
595
  let anomalyScore = 0;
595
- // Pénalité forte si le User-Agent est manquant ou très court (signe de script simple)
596
+ // Strong penalty if User-Agent is missing or very short (sign of a simple script)
596
597
  if (!req.headers["user-agent"] || req.headers["user-agent"].length < 10) {
597
598
  anomalyScore += 60;
598
599
  }
599
- // Pénalité si le header Accept-Language est manquant
600
+ // Penalty if Accept-Language header is missing
600
601
  if (!req.headers["accept-language"]) {
601
602
  anomalyScore += 25;
602
603
  }
603
- // Pénalité pour les requêtes HTTP/1.0, souvent utilisées par des outils anciens ou des bots
604
+ // Penalty for HTTP/1.0 requests, often used by old tools or bots
604
605
  if (req.httpVersion === "1.0") {
605
606
  anomalyScore += 15;
606
607
  }
607
608
 
608
- // NOUVEAU : Score d'incohérence (cookie volé ?)
609
- // Si le score de cohérence est bas, on ajoute une pénalité massive.
610
- // Un score de 0.2 signifie une différence énorme.
609
+ // NEW: Inconsistency score (stolen cookie?)
610
+ // If the consistency score is low, add a massive penalty.
611
+ // A score of 0.2 means a huge difference.
611
612
  const inconsistencyScore = Math.max(0, (1 - consistencyScore) * 200);
612
613
 
613
614
  return {
@@ -625,8 +626,8 @@ function getHeaderAnomalies(req, consistencyScore) {
625
626
  */
626
627
 
627
628
  /**
628
- * Implémentation par défaut du store, en mémoire.
629
- * @type {IStore}
629
+ * Default in-memory store implementation.
630
+ * @type {IStore}
630
631
  */
631
632
  const inMemoryStore = {
632
633
  _map: new Map(),
@@ -640,26 +641,26 @@ const inMemoryStore = {
640
641
  let store = inMemoryStore;
641
642
 
642
643
  /**
643
- * Permet de configurer un datastore externe (ex: Redis).
644
- * Doit être appelée avant que le middleware ne soit utilisé.
645
- * @param {IStore} externalStore - Une implémentation de l'interface IStore.
644
+ * Allows configuring an external datastore (e.g., Redis).
645
+ * Must be called before the middleware is used.
646
+ * @param {IStore} externalStore - An implementation of the IStore interface.
646
647
  */
647
648
  export const configureStore = (externalStore) => {
648
649
  store = externalStore;
649
650
  };
650
651
 
651
652
  /**
652
- * Orchestre l'identification de la requête en utilisant une ancre persistante (cookie)
653
- * et une vérification par empreinte.
654
- * @param {object} req - L'objet de la requête Express.
655
- * @param {object} res - L'objet de la réponse Express (pour poser le cookie).
656
- * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number}>}
653
+ * Orchestrates request identification using a persistent anchor (cookie)
654
+ * and fingerprint verification.
655
+ * @param {object} req - The Express request object.
656
+ * @param {object} res - The Express response object (to set the cookie).
657
+ * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number}>}
657
658
  */
658
659
  async function resolveRequestIdentity(req, res) {
659
660
  const existingDeviceId = req.cookies?.device_id;
660
661
  const currentDeviceHash = getDeviceHash(req);
661
662
  let deviceId = existingDeviceId;
662
- let consistencyScore = 1.0; // 1.0 = parfaitement cohérent
663
+ let consistencyScore = 1.0; // 1.0 = perfectly consistent
663
664
  let deviceData = null;
664
665
 
665
666
  if (deviceId) {
@@ -667,36 +668,36 @@ async function resolveRequestIdentity(req, res) {
667
668
  }
668
669
 
669
670
  if (deviceData) {
670
- // Cas 1: L'utilisateur a un "passeport" et nous le connaissons.
671
+ // Case 1: The user has a "passport" and we know them.
671
672
  const storedHash = deviceData.initialDeviceHash;
672
673
 
673
- // Comparaison de l'empreinte actuelle avec celle de référence.
674
+ // Compare the current fingerprint with the reference one.
674
675
  consistencyScore = FingerprintBuilder.compare(
675
676
  storedHash,
676
677
  currentDeviceHash,
677
678
  );
678
679
  } else {
679
- // Cas 2: Nouvel utilisateur ou cookie perdu/invalide.
680
- deviceId = crypto.randomUUID(); // On génère un nouveau "passeport".
680
+ // Case 2: New user or lost/invalid cookie.
681
+ deviceId = crypto.randomUUID(); // Generate a new "passport".
681
682
 
682
- // On pose le cookie de manière sécurisée.
683
+ // Set the cookie securely.
683
684
  res.cookie("device_id", deviceId, {
684
685
  httpOnly: true,
685
686
  secure: process.env.NODE_ENV === "production",
686
687
  sameSite: "strict",
687
- maxAge: 31536000000, // 1 an
688
+ maxAge: 31536000000, // 1 year
688
689
  });
689
690
 
690
- // On initialise le suivi pour ce nouvel appareil.
691
+ // Initialize tracking for this new device.
691
692
  deviceData = {
692
- initialDeviceHash: currentDeviceHash, // On ancre l'empreinte initiale.
693
+ initialDeviceHash: currentDeviceHash, // Anchor the initial fingerprint.
693
694
  ips: new Set(),
694
695
  lastUpdate: Date.now(),
695
696
  lastFpHash: currentDeviceHash,
696
697
  lastChangeTimestamp: 0,
697
698
  rapidChangeCount: 0,
698
699
  };
699
- // L'écriture se fera dans getSuspicionVector après toutes les modifications.
700
+ // The write will happen in getSuspicionVector after all modifications.
700
701
  }
701
702
 
702
703
  return { deviceId, deviceData, consistencyScore };
@@ -704,37 +705,37 @@ async function resolveRequestIdentity(req, res) {
704
705
 
705
706
  /*
706
707
  * Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
707
- * @param {object} req - L'objet de la requête Express.
708
- * @param {object} deviceData - Les données d'activité de l'appareil.
708
+ * @param {object} req - The Express request object.
709
+ * @param {object} deviceData - The device's activity data.
709
710
  * @returns {Promise<{historyScore: number, rotationScore: number}>}
710
711
  */
711
712
  async function getBehavioralIndicators(req, deviceData) {
712
713
  const now = Date.now();
713
714
  const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
714
715
 
715
- // On récupère le type d'IP pour moduler le score
716
+ // Get the IP type to modulate the score
716
717
  const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
717
718
  const isSharedIp = ipProfile.type === "shared";
718
719
 
719
- const currentFpHash = getDeviceHash(req); // On utilise le hash de l'appareil
720
+ const currentFpHash = getDeviceHash(req); // Use the device hash
720
721
 
721
- // --- Analyse de comportement (Fréquence de changement) ---
722
+ // --- Behavior analysis (Change frequency) ---
722
723
  if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
723
724
  const timeSinceLastChange = now - deviceData.lastChangeTimestamp;
724
725
 
725
726
  if (timeSinceLastChange < RAPID_CHANGE_THRESHOLD_MS) {
726
727
  deviceData.rapidChangeCount = Math.min(
727
728
  deviceData.rapidChangeCount + 1,
728
- MAX_RAPID_CHANGES_PER_DEVICE * 2,
729
- ); // Augmente rapidement
729
+ MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
730
+ );
730
731
  } else {
731
- deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Diminue lentement
732
+ deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
732
733
  }
733
734
  deviceData.lastChangeTimestamp = now;
734
735
  }
735
736
 
736
737
  deviceData.lastFpHash = currentFpHash;
737
- deviceData.ips.add(clientIp); // On enregistre l'IP utilisée par cet appareil
738
+ deviceData.ips.add(clientIp); // Record the IP used by this device
738
739
 
739
740
  // NOUVELLE LOGIQUE : Le score d'historique est basé sur le nombre d'IPs utilisées par l'appareil.
740
741
  // Très efficace contre la rotation de proxy.
@@ -750,7 +751,7 @@ async function getBehavioralIndicators(req, deviceData) {
750
751
  100,
751
752
  );
752
753
 
753
- // Score basé sur la rotation rapide d'identité (0-100)
754
+ // Score based on rapid identity rotation (0-100)
754
755
  const rotationScore = Math.min(
755
756
  100,
756
757
  (deviceData.rapidChangeCount / MAX_RAPID_CHANGES_PER_DEVICE) * 100,
@@ -760,17 +761,17 @@ async function getBehavioralIndicators(req, deviceData) {
760
761
  }
761
762
 
762
763
  /**
763
- * Retourne un vecteur de scores de suspicion bruts (non pondérés).
764
- * @param {object} req - L'objet de la requête Express.
764
+ * Returns a vector of raw (unweighted) suspicion scores.
765
+ * @param {object} req - The Express request object.
765
766
  * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number}>}
766
767
  */
767
768
  export const getSuspicionVector = async (req, res) => {
768
769
  const { deviceId, deviceData, consistencyScore } = await resolveRequestIdentity(req, res);
769
770
 
770
771
  const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
771
- await store.set(`ip-device:${clientIp}`, deviceId); // On lie l'IP à l'appareil
772
+ await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
772
773
 
773
- // Nettoyage périodique des données de l'appareil
774
+ // Periodically clean up device data
774
775
  if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
775
776
  deviceData.ips.clear();
776
777
  deviceData.rapidChangeCount = 0;
@@ -780,13 +781,13 @@ export const getSuspicionVector = async (req, res) => {
780
781
  const behavioral = await getBehavioralIndicators(req, deviceData);
781
782
  const anomalies = getHeaderAnomalies(req, consistencyScore);
782
783
 
783
- // Sauvegarde l'état mis à jour de l'appareil dans le store
784
+ // Save the updated device state to the store
784
785
  await store.set(`device:${deviceId}`, deviceData);
785
786
 
786
787
  return { ...behavioral, ...anomalies };
787
788
  };
788
789
 
789
- // Un utilisateur résidentiel peut changer de réseau (maison, 4G, wifi public).
790
+ // A residential user can change networks (home, 4G, public wifi).
790
791
  const MAX_DISTINCT_IPS_PER_DEVICE = 15;
791
792
  // Un utilisateur derrière un NAT/proxy ne devrait pas utiliser BEAUCOUP d'autres IPs.
792
793
  const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
@@ -795,18 +796,18 @@ const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
795
796
  const SHARED_IP_DEVICE_THRESHOLD = 50;
796
797
 
797
798
  const RAPID_CHANGE_THRESHOLD_MS = 2000; // 2 secondes
798
- const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Nombre de changements rapides d'empreinte autorisés par appareil.
799
+ const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes allowed per device.
799
800
 
800
801
  /**
801
- * Identifie une requête côté serveur de manière granulaire.
802
- * Utilise le FingerprintBuilder pour créer une empreinte basée sur les headers
803
- * et l'IP, rendant le spoofing plus complexe (nécessite de changer toute la stack).
802
+ * Identifies a request on the server side in a granular way.
803
+ * Uses FingerprintBuilder to create a fingerprint based on headers
804
+ * and IP, making spoofing more complex (requires changing the entire stack).
804
805
  */
805
806
  export const identifyRequest = async (req, res) => {
806
807
  const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
807
808
  const deviceId = req.cookies?.device_id;
808
809
 
809
- // --- Mise à jour de la réputation de l'IP ---
810
+ // --- Update IP reputation ---
810
811
  const ipProfile = (await store.get(`ip:${clientIp}`)) || {
811
812
  type: "residential",
812
813
  deviceIds: new Set(),
@@ -817,35 +818,35 @@ export const identifyRequest = async (req, res) => {
817
818
  if (deviceId) {
818
819
  ipProfile.deviceIds.add(deviceId);
819
820
  } else {
820
- // Logique anti "Bot Amnésique" améliorée
821
+ // Improved anti-"Amnesiac Bot" logic
821
822
  ipProfile.statelessCount++;
822
823
  }
823
824
 
824
- // Si une IP voit trop d'appareils différents, on la classe comme "partagée".
825
+ // If an IP sees too many different devices, classify it as "shared".
825
826
  if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
826
827
  ipProfile.type = "shared";
827
828
  }
828
829
 
829
- // Si une IP résidentielle fait trop de requêtes sans cookie, c'est un bot.
830
- // Pour une IP partagée, on est plus tolérant car de nouveaux utilisateurs arrivent constamment.
830
+ // If a residential IP makes too many requests without a cookie, it's a bot.
831
+ // For a shared IP, we are more tolerant because new users are constantly arriving.
831
832
  const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
832
833
  if (ipProfile.statelessCount > statelessLimit) {
833
834
  return `suspicious_high:${clientIp}`;
834
835
  }
835
836
  await store.set(`ip:${clientIp}`, ipProfile);
836
837
 
837
- // Pour la compatibilité avec le rate-limiter, on calcule un score simple.
838
- // Le PoW utilisera le système pondéré, plus complexe.
838
+ // For compatibility with the rate-limiter, calculate a simple score.
839
+ // The PoW will use the more complex weighted system.
839
840
  const vector = await getSuspicionVector(req, res);
840
841
  const score =
841
842
  vector.historyScore * 0.3 +
842
843
  vector.rotationScore * 0.5 +
843
844
  vector.headerAnomalyScore * 0.1 +
844
- vector.inconsistencyScore * 0.8; // L'incohérence est un signal très fort
845
+ vector.inconsistencyScore * 0.8; // Inconsistency is a very strong signal
845
846
 
846
- // On retourne une chaîne de caractères pour la compatibilité avec les rate limiters,
847
- // mais basée sur les seuils de suspicion.
848
- // NOTE : Ces seuils sont fixes ici, mais le PoW utilisera les seuils dynamiques.
847
+ // Return a string for compatibility with rate limiters,
848
+ // but based on suspicion thresholds.
849
+ // NOTE: These thresholds are fixed here, but the PoW will use dynamic thresholds.
849
850
  if (score >= 75) {
850
851
  return `suspicious_high:${clientIp}`;
851
852
  }
@@ -853,8 +854,8 @@ export const identifyRequest = async (req, res) => {
853
854
  return `suspicious_medium:${clientIp}`;
854
855
  }
855
856
 
856
- // Pour les requêtes normales, on retourne un hash de l'empreinte pour le rate limiting.
857
- // On utilise le hash de l'appareil pour que le rate-limit suive l'appareil, pas l'IP.
857
+ // For normal requests, return a hash of the fingerprint for rate limiting.
858
+ // Use the device hash so the rate-limit follows the device, not the IP.
858
859
  const deviceIdForIp = await store.get(`ip-device:${clientIp}`);
859
860
  const finalDeviceId = deviceId || deviceIdForIp || clientIp;
860
861
  return `device:${finalDeviceId}`;
@@ -862,34 +863,37 @@ export const identifyRequest = async (req, res) => {
862
863
  // --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
863
864
 
864
865
  // Le plus grand nombre possible avec SHA-256 (2^256 - 1)
866
+ // The largest possible number with SHA-256 (2^256 - 1)
865
867
  const MAX_DIFFICULTY_TARGET = 2n ** 256n - 1n;
866
868
  // Une difficulté de base, ex: nécessite que les 16 premiers bits soient à 0
867
869
  // (équivalent à 4 zéros en hexadécimal)
870
+ // A base difficulty, e.g., requires the first 16 bits to be 0
871
+ // (equivalent to 4 zeros in hexadecimal)
868
872
  const BASE_TARGET = MAX_DIFFICULTY_TARGET >> 16n;
869
873
 
870
874
  /**
871
- * Calcule le target de difficulté en fonction du facteur de suspicion.
872
- * @param {number} suspicionFactor - Un nombre de 0 à 1.
873
- * @returns {BigInt} Le nombre cible.
875
+ * Calculates the difficulty target based on the suspicion factor.
876
+ * @param {number} suspicionFactor - A number from 0 to 1.
877
+ * @returns {BigInt} The target number.
874
878
  */
875
879
  function calculateTarget(suspicionFactor) {
876
- // Plage de difficulté ajustée pour être réaliste.
877
- // MIN_DIFFICULTY: Assez rapide pour ne pas gêner un utilisateur légèrement suspect.
878
- // MAX_DIFFICULTY: Assez lent pour pénaliser lourdement un bot, mais faisable pour un humain patient (5-30s).
879
- const MIN_DIFFICULTY_BITS = 18; // Valeur par défaut, devrait être configurable
880
- const MAX_DIFFICULTY_BITS = 26; // Valeur par défaut, devrait être configurable
880
+ // Difficulty range adjusted to be realistic.
881
+ // MIN_DIFFICULTY: Fast enough not to bother a slightly suspicious user.
882
+ // MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
883
+ const MIN_DIFFICULTY_BITS = 18; // Default value, should be configurable
884
+ const MAX_DIFFICULTY_BITS = 26; // Default value, should be configurable
881
885
 
882
- // On utilise une interpolation linéaire entre la difficulté min et max.
886
+ // Use linear interpolation between min and max difficulty.
883
887
  const totalDifficultyBits =
884
888
  MIN_DIFFICULTY_BITS +
885
889
  suspicionFactor * (MAX_DIFFICULTY_BITS - MIN_DIFFICULTY_BITS);
886
890
 
887
- // Le target est le max / 2^bits
891
+ // The target is max / 2^bits
888
892
  return MAX_DIFFICULTY_TARGET >> BigInt(Math.floor(totalDifficultyBits));
889
893
  }
890
894
 
891
895
  /**
892
- * Génère un challenge CPU basé sur un target.
896
+ * Generates a CPU challenge based on a target.
893
897
  */
894
898
  export function generateCpuTargetChallenge(
895
899
  clientIp,
@@ -901,7 +905,7 @@ export function generateCpuTargetChallenge(
901
905
  return {
902
906
  type: "cpu_target",
903
907
  nonce: nonce,
904
- target: target.toString(16), // On envoie le target en hexadécimal
908
+ target: target.toString(16), // Send the target in hexadecimal
905
909
  path: originalUrl,
906
910
  };
907
911
  }
@@ -942,7 +946,67 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
942
946
  }
943
947
 
944
948
  /**
945
- * Vérifie une solution de PoW basée sur un target et génère un ticket.
949
+ * Generates the HTML content for a combined CPU + Memory PoW challenge.
950
+ * @param {object} cpuChallengeDetails - Details from generateCpuTargetChallenge.
951
+ * @param {number} memoryDifficulty - Memory allocation in MB.
952
+ * @param {string} clientIp - The client's IP address.
953
+ * @returns {string} HTML content.
954
+ */
955
+ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp) {
956
+ const { nonce, target, path } = cpuChallengeDetails;
957
+ return `
958
+ <html><head><title>Advanced Security Check</title></head>
959
+ <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
960
+ <h1>Enhanced Verification... (Level 2)</h1>
961
+ <p>Your activity requires an additional security check. This may take a few moments.</p>
962
+ <div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div>
963
+ <script>
964
+ async function solve() {
965
+ const nonce = "${nonce}";
966
+ const path = "${path}";
967
+
968
+ // --- CPU Challenge ---
969
+ document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
970
+ const cpuTarget = BigInt("0x${target}");
971
+ let cpuSolution = 0;
972
+ while (true) {
973
+ const msg = "${clientIp}:${nonce}:" + cpuSolution;
974
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
975
+ const hashHex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
976
+ if (BigInt('0x' + hashHex) < cpuTarget) break;
977
+ cpuSolution++;
978
+ if (cpuSolution % 100000 === 0) await new Promise(r => setTimeout(r, 0));
979
+ }
980
+
981
+ // --- Memory Challenge ---
982
+ document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (${memoryDifficulty} MB)';
983
+ await new Promise(r => setTimeout(r, 10)); // Yield to update UI
984
+
985
+ let memSolution = 0;
986
+ try {
987
+ const size = ${memoryDifficulty} * 1024 * 1024;
988
+ const buffer = new Uint32Array(size / 4);
989
+ let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
990
+ for (let i = 0; i < buffer.length; i++) {
991
+ buffer[i] = (h = Math.imul(h ^ i, 1597334677));
992
+ }
993
+ for(let i = 0; i < (size / 16); i++) {
994
+ const addr = buffer[i % buffer.length] % buffer.length;
995
+ memSolution ^= buffer[addr];
996
+ }
997
+ } catch(e) {
998
+ document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
999
+ return;
1000
+ }
1001
+ window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
1002
+ }
1003
+ solve();
1004
+ </script>
1005
+ </body></html>`;
1006
+ }
1007
+
1008
+ /**
1009
+ * Verifies a PoW solution based on a target and generates a ticket.
946
1010
  */
947
1011
  export function verifyCpuTargetPoWAndGenerateTicket(
948
1012
  clientIp,
@@ -958,8 +1022,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
958
1022
  const hashAsInt = BigInt("0x" + hash);
959
1023
 
960
1024
  if (hashAsInt < target) {
961
- // La comparaison est directe avec les BigInt natifs
962
- // La preuve est valide, on génère le ticket
1025
+ // The comparison is direct with native BigInts
1026
+ // The proof is valid, generate the ticket
963
1027
  const expiry = Date.now() + 3600000; // 1 heure
964
1028
  const signature = crypto
965
1029
  .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
@@ -976,51 +1040,62 @@ const staticExtensions =
976
1040
  const isStaticResource = (req) => staticExtensions.test(req.path);
977
1041
 
978
1042
  // --- Middleware Proof-of-Work (Le péage) ---
979
- export const powMiddleware = (securityConfig) => async (req, res, next) => {
980
- // On ignore le PoW pour les ressources statiques (images, scripts, fonts)
981
- if (isStaticResource(req)) {
982
- return next();
983
- }
984
-
1043
+ class FingerprintEngine {
1044
+ constructor(securityConfig) {
985
1045
  const isProduction = process.env.NODE_ENV === 'production';
986
- const { weights, thresholds } = securityConfig;
1046
+ this.securityConfig = securityConfig;
1047
+ this.isProduction = isProduction;
1048
+ }
987
1049
 
988
- const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
989
- // On récupère le vecteur de suspicion et on calcule le score final pondéré
990
- const suspicionVector = await __internal.getSuspicionVector(req, res);
1050
+ async processRequest(requestContext) {
1051
+ const { clientIp, path, cookies, query, isStatic } = requestContext;
1052
+ const { weights, thresholds, logger } = this.securityConfig;
1053
+
1054
+ if (isStatic) {
1055
+ return { action: 'next' };
1056
+ }
1057
+
1058
+ // We need to pass `req` and `res` to getSuspicionVector for cookie handling.
1059
+ // This is a remaining coupling point that could be refactored further.
1060
+ const suspicionVector = await __internal.getSuspicionVector(requestContext.rawReq, requestContext.rawRes);
991
1061
 
992
1062
  const finalScore =
993
- suspicionVector.historyScore * weights.historyScore +
994
- suspicionVector.rotationScore * weights.rotationScore +
995
- suspicionVector.headerAnomalyScore * weights.headerAnomalyScore +
996
- suspicionVector.inconsistencyScore * weights.inconsistencyScore;
1063
+ suspicionVector.historyScore * (weights.historyScore || 0) +
1064
+ suspicionVector.rotationScore * (weights.rotationScore || 0) +
1065
+ suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) +
1066
+ suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0);
997
1067
 
998
1068
  const isSuspiciousHigh = finalScore >= thresholds.high;
999
1069
  const isSuspiciousMedium = finalScore >= thresholds.medium;
1000
1070
  const isSuspicious = finalScore >= thresholds.low;
1001
1071
 
1002
- // Calcul d'un "facteur de suspicion" analogique (0 à 1+) pour une difficulté progressive
1072
+ // Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
1003
1073
  const suspicionFactor = isSuspicious
1004
1074
  ? Math.min(
1005
1075
  1,
1006
1076
  (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
1007
1077
  )
1008
1078
  : 0;
1009
- const powCookie = req.cookies?.pow_clearance;
1010
- const { pow_type, pow_nonce, pow_solution, captcha_token } = req.query;
1079
+ const powCookie = cookies?.pow_clearance;
1080
+ const { pow_type, pow_nonce, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1081
+
1082
+ // Basic log for each non-static request
1083
+ if (logger && !isSuspicious) {
1084
+ logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1085
+ }
1011
1086
 
1012
1087
  if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
1013
- // --- GESTION DES RÉPONSES AUX CHALLENGES ---
1014
- if (pow_nonce && pow_solution) {
1088
+ // --- CHALLENGE SOLUTION HANDLING ---
1089
+ if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1015
1090
  let isValid = false,
1016
1091
  ticket = null;
1017
1092
  if (pow_type === "cpu_target") {
1018
- // On vérifie le nouveau type
1093
+ // Verify the new type
1019
1094
  ticket = verifyCpuTargetPoWAndGenerateTicket(
1020
1095
  clientIp,
1021
1096
  pow_nonce,
1022
1097
  pow_solution,
1023
- suspicionFactor, // On passe directement le facteur analogique
1098
+ suspicionFactor, // Pass the analog factor directly
1024
1099
  );
1025
1100
  isValid = ticket !== null;
1026
1101
  } else if (pow_type === "mem") {
@@ -1029,14 +1104,27 @@ export const powMiddleware = (securityConfig) => async (req, res, next) => {
1029
1104
  const difficulty =
1030
1105
  minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
1031
1106
  isValid = verifyMemoryPoW(pow_nonce, pow_solution, difficulty);
1107
+ } else if (pow_type === "cpu_mem") {
1108
+ // Verify combined challenge
1109
+ const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
1110
+ clientIp, pow_nonce, pow_solution_cpu, suspicionFactor
1111
+ );
1112
+
1113
+ const minDifficulty = 16; // 16Mo
1114
+ const maxDifficulty = 48; // 48Mo
1115
+ const memDifficulty = minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
1116
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty);
1117
+
1118
+ isValid = cpuTicket !== null && isMemValid;
1119
+ if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
1032
1120
  } else if (pow_type === "tsp") {
1033
- // La logique pour TSP reste la même
1121
+ // Logic for TSP remains the same
1034
1122
  // ...
1035
1123
  }
1036
1124
 
1037
1125
  if (isValid) {
1038
1126
  if (!ticket) {
1039
- // Si le ticket n'a pas déjà été généré (cas CPU)
1127
+ // If the ticket has not already been generated (CPU case)
1040
1128
  const expiry = Date.now() + 3600000; // 1 heure
1041
1129
  const signature = crypto
1042
1130
  .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
@@ -1045,50 +1133,110 @@ export const powMiddleware = (securityConfig) => async (req, res, next) => {
1045
1133
  ticket = `${expiry}:${signature}`;
1046
1134
  }
1047
1135
 
1048
- res.cookie("pow_clearance", ticket, {
1049
- httpOnly: true,
1050
- secure: isProduction,
1051
- maxAge: 3600000,
1052
- });
1053
- return res.redirect(req.path); // Recharge la page sans les params
1136
+ if (logger) {
1137
+ logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: finalScore, challengeType: pow_type, timestamp: Date.now() });
1138
+ }
1139
+
1140
+ return {
1141
+ action: 'redirect',
1142
+ path: path,
1143
+ cookie: {
1144
+ name: 'pow_clearance',
1145
+ value: ticket,
1146
+ options: {
1147
+ httpOnly: true,
1148
+ secure: this.isProduction,
1149
+ maxAge: 3600000,
1150
+ }
1151
+ }
1152
+ };
1054
1153
  }
1055
1154
  }
1056
1155
 
1057
- // --- SÉLECTION ET ENVOI DU CHALLENGE APPROPRIÉ ---
1156
+ // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
1058
1157
  const nonce = crypto.randomBytes(16).toString("hex");
1059
1158
 
1060
- // NIVEAU 3 : CAPTCHA (le plus élevé)
1159
+ if (logger) {
1160
+ logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1161
+ }
1162
+
1163
+ // LEVEL 3: CAPTCHA (the highest)
1061
1164
  if (isSuspiciousHigh) {
1062
- // ... logique pour le challenge TSP/Captcha
1165
+ // ... logic for TSP/Captcha challenge
1063
1166
  }
1064
1167
 
1065
- // NIVEAU 2 : PoW Gourmand en Mémoire
1168
+ // LEVEL 2: Memory-Intensive PoW
1066
1169
  if (isSuspiciousMedium) {
1067
- const minDifficulty = 16; // 16Mo
1068
- const maxDifficulty = 48; // 48Mo
1069
- const difficulty =
1070
- minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
1071
- return res.status(429).send(
1072
- generateMemoryPoWChallenge(clientIp, nonce, difficulty, req.path),
1073
- // NOTE: Pour que le challenge mémoire fonctionne, il faudra aussi
1074
- // l'intégrer dans `generateChallengePage` et le script client.
1075
- );
1170
+ // Utilisons notre nouveau challenge combiné !
1171
+ const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
1172
+
1173
+ const minMemDifficulty = 16; // 16Mo
1174
+ const maxMemDifficulty = 48; // 48Mo
1175
+ const memDifficulty = minMemDifficulty + suspicionFactor * (maxMemDifficulty - minMemDifficulty);
1176
+
1177
+ const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
1178
+ return {
1179
+ action: 'challenge',
1180
+ status: 429, body: page
1181
+ };
1076
1182
  }
1077
- // NIVEAU 1 : PoW CPU Standard
1078
- if (isSuspicious) {
1079
- // On récupère les détails du challenge
1080
- const challengeDetails = generateCpuTargetChallenge(
1081
- clientIp,
1082
- nonce,
1083
- suspicionFactor,
1084
- req.path,
1085
- );
1086
- // On génère la page HTML avec le solveur intégré
1087
- const challengePage = generateCpuTargetChallengePage(challengeDetails, clientIp);
1088
- return res.status(429).send(challengePage);
1183
+
1184
+ // NOUVELLE LOGIQUE UNIFIÉE POUR TOUS LES NIVEAUX DE SUSPICION (low et medium)
1185
+ if (isSuspicious) { // Couvre à la fois low et medium
1186
+ const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
1187
+
1188
+ // La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
1189
+ // Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
1190
+ const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
1191
+
1192
+ const minMemDifficulty = 0; // Peut être 0 Mo !
1193
+ const maxMemDifficulty = 48; // 48Mo pour les plus suspects
1194
+ const memDifficulty = minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty);
1195
+
1196
+ // On utilise toujours la page combinée, même si la difficulté mémoire est 0 (le calcul sera quasi instantané).
1197
+ const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
1198
+ return { action: 'challenge', status: 429, body: page };
1089
1199
  }
1090
1200
  }
1091
- next();
1201
+
1202
+ return { action: 'next' };
1203
+ }
1204
+ }
1205
+
1206
+ // --- Proof-of-Work Middleware (The Tollbooth) ---
1207
+ export const powMiddleware = (securityConfig) => {
1208
+ const engine = new FingerprintEngine(securityConfig);
1209
+
1210
+ return async (req, res, next) => {
1211
+ const requestContext = {
1212
+ clientIp: req.ip || req.socket?.remoteAddress || "unknown",
1213
+ path: req.path,
1214
+ cookies: req.cookies,
1215
+ query: req.query,
1216
+ headers: req.headers,
1217
+ isStatic: isStaticResource(req),
1218
+ // Pass raw req/res for now to handle cookie setting in resolveRequestIdentity
1219
+ rawReq: req,
1220
+ rawRes: res,
1221
+ };
1222
+
1223
+ const decision = await engine.processRequest(requestContext);
1224
+
1225
+ switch (decision.action) {
1226
+ case 'challenge':
1227
+ return res.status(decision.status).send(decision.body);
1228
+
1229
+ case 'redirect':
1230
+ if (decision.cookie) {
1231
+ res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
1232
+ }
1233
+ return res.redirect(decision.path);
1234
+
1235
+ case 'next':
1236
+ default:
1237
+ return next();
1238
+ }
1239
+ };
1092
1240
  };
1093
1241
 
1094
1242
  /**
@@ -1099,4 +1247,128 @@ export const powMiddleware = (securityConfig) => async (req, res, next) => {
1099
1247
  export const __internal = {
1100
1248
  getSuspicionVector,
1101
1249
  calculateTarget,
1250
+ FingerprintEngine, // Expose for advanced testing
1102
1251
  };
1252
+
1253
+ // --- THRESHOLD AUTO-TUNING SECTION ---
1254
+
1255
+ let autoTuningJobId = null;
1256
+
1257
+ /**
1258
+ * Executes a threshold optimization pass using collected traffic data.
1259
+ * @private
1260
+ * @param {object} securityConfig - The security configuration object to update.
1261
+ * @param {Array<object>} trafficData - The array containing traffic logs.
1262
+ * @param {number} minDataPoints - The minimum number of data points required to start optimization.
1263
+ */
1264
+ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
1265
+ if (trafficData.length < minDataPoints) {
1266
+ console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
1267
+ return;
1268
+ }
1269
+ console.log(`[AutoTuning] Démarrage du cycle d'optimisation avec ${trafficData.length} points de données.`);
1270
+
1271
+ // Identify "bots" (those who received a challenge but never solved it)
1272
+ // and "humans" (those who passed the challenge or never received one).
1273
+ const solvedDevices = new Set(trafficData.filter(e => e.type === 'challenge_solved').map(e => e.deviceId));
1274
+ const historicalRequests = trafficData.map(log => {
1275
+ let isBot = false;
1276
+ if (log.type === 'challenge_issued' && !solvedDevices.has(log.deviceId)) {
1277
+ isBot = true; // Assumption: a challenge issued and not solved is a bot.
1278
+ }
1279
+ return { score: log.score, isBot };
1280
+ });
1281
+
1282
+ // The "fitness" function evaluates the quality of a set of thresholds.
1283
+ // A lower score is better.
1284
+ const fitnessFunction = (solution) => {
1285
+ const [low, medium, high] = solution;
1286
+ // Constraints: thresholds must be ordered and within a reasonable range.
1287
+ if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
1288
+
1289
+ let falsePositives = 0; // Humans challenged unnecessarily.
1290
+ let falseNegatives = 0; // Undetected bots.
1291
+
1292
+ for (const req of historicalRequests) {
1293
+ if (req.isBot) {
1294
+ if (req.score < low) falseNegatives++;
1295
+ } else { // Human
1296
+ if (req.score >= low) falsePositives++;
1297
+ }
1298
+ }
1299
+ // Penalize passing bots 2x more than inconvenienced humans.
1300
+ return (falsePositives * 1.0) + (falseNegatives * 2.0);
1301
+ };
1302
+
1303
+ // Functions for the genetic algorithm.
1304
+ const createIndividual = () => [10 + Math.random() * 20, 30 + Math.random() * 30, 60 + Math.random() * 30];
1305
+ const crossover = (p1, p2) => [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
1306
+ const mutate = (s) => {
1307
+ const n = [...s];
1308
+ const i = Math.floor(Math.random() * 3);
1309
+ n[i] += (Math.random() - 0.5) * 5;
1310
+ return n;
1311
+ };
1312
+
1313
+ // Start optimization.
1314
+ const result = Optimization.geneticAlgorithm(createIndividual, fitnessFunction, crossover, mutate, {
1315
+ generations: 50,
1316
+ populationSize: 40
1317
+ });
1318
+
1319
+ const [newLow, newMedium, newHigh] = result.solution;
1320
+
1321
+ // Update the configuration live.
1322
+ securityConfig.thresholds = {
1323
+ low: Math.round(newLow),
1324
+ medium: Math.round(newMedium),
1325
+ high: Math.round(newHigh)
1326
+ };
1327
+
1328
+ console.log("[AutoTuning] Nouveaux seuils optimisés appliqués :", securityConfig.thresholds);
1329
+ }
1330
+
1331
+ /**
1332
+ * Starts the background process for auto-tuning security thresholds.
1333
+ * @export
1334
+ * @param {object} options - Configuration options for auto-tuning.
1335
+ * @param {object} options.securityConfig - The live security configuration object that will be mutated.
1336
+ * @param {Array<object>} options.trafficData - The array where the logger pushes traffic data.
1337
+ * @param {number} [options.interval=1800000] - The interval in milliseconds between each optimization cycle (default: 30 minutes).
1338
+ * @param {number} [options.minDataPoints=200] - The minimum number of requests to analyze before starting a cycle (default: 200).
1339
+ */
1340
+ export function startThresholdAutoTuning(options) {
1341
+ if (autoTuningJobId) {
1342
+ console.warn("[AutoTuning] Le job est déjà en cours d'exécution.");
1343
+ return;
1344
+ }
1345
+
1346
+ const {
1347
+ securityConfig,
1348
+ trafficData,
1349
+ interval = 1800000,
1350
+ minDataPoints = 200
1351
+ } = options;
1352
+
1353
+ if (!securityConfig || !trafficData) {
1354
+ throw new Error("[AutoTuning] `securityConfig` et `trafficData` sont requis.");
1355
+ }
1356
+
1357
+ console.log(`[AutoTuning] Job d'optimisation des seuils démarré. Prochain cycle dans ${interval / 60000} minutes.`);
1358
+
1359
+ autoTuningJobId = setInterval(() => {
1360
+ runThresholdOptimization(securityConfig, trafficData, minDataPoints);
1361
+ }, interval);
1362
+ }
1363
+
1364
+ /**
1365
+ * Stops the threshold auto-tuning process.
1366
+ * @export
1367
+ */
1368
+ export function stopThresholdAutoTuning() {
1369
+ if (autoTuningJobId) {
1370
+ clearInterval(autoTuningJobId);
1371
+ autoTuningJobId = null;
1372
+ console.log("[AutoTuning] Job d'optimisation des seuils arrêté.");
1373
+ }
1374
+ }