@anonympins/fingerprint 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fingerprint.js ADDED
@@ -0,0 +1,1102 @@
1
+ // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
+ import crypto from "node:crypto";
3
+
4
+ const POW_SECRET = process.env.POW_SECRET;
5
+
6
+ if (!POW_SECRET && process.env.NODE_ENV === 'production') {
7
+ throw new Error('POW_SECRET environment variable is not set. This is required for production.');
8
+ } else if (!POW_SECRET) {
9
+ console.warn('Warning: POW_SECRET environment variable not set. Using a default, insecure secret for development.');
10
+ }
11
+ /**
12
+ * Algorithme de hachage cyrb53 (rapide et faible taux de collision). Exporté pour réutilisation.
13
+ */
14
+ export const cyrb53 = (str, seed = 0) => {
15
+ let h1 = 0xdeadbeef ^ seed,
16
+ h2 = 0x41c6ce57 ^ seed;
17
+ for (let i = 0, ch; i < str.length; i++) {
18
+ ch = str.charCodeAt(i);
19
+ h1 = Math.imul(h1 ^ ch, 2654435761);
20
+ h2 = Math.imul(h2 ^ ch, 1597334677);
21
+ }
22
+ h1 =
23
+ Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
24
+ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
25
+ h2 =
26
+ Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
27
+ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
28
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
29
+ };
30
+
31
+ /**
32
+ * Classe pour construire une empreinte composite (Multi-Hash).
33
+ * Format de sortie : "grp1:hash1|grp2:hash2|grp3:hash3"
34
+ */
35
+ export class FingerprintBuilder {
36
+ constructor() {
37
+ this.components = new Map();
38
+ }
39
+
40
+ /**
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
44
+ */
45
+ add(group, value) {
46
+ if (value === undefined || value === null) return this;
47
+ // On hash la valeur individuellement pour l'anonymiser et réduire sa taille
48
+ this.components.set(group, cyrb53(String(value)));
49
+ return this;
50
+ }
51
+
52
+ /**
53
+ * Génère la chaîne de signature finale.
54
+ * Trie les clés pour garantir un ordre déterministe.
55
+ */
56
+ toString() {
57
+ return Array.from(this.components.entries())
58
+ .sort((a, b) => a[0].localeCompare(b[0])) // Tri alphabétique des clés
59
+ .map(([key, hash]) => `${key}:${hash}`)
60
+ .join("|");
61
+ }
62
+
63
+ /**
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
68
+ */
69
+ static compare(fpString1, fpString2) {
70
+ if (!fpString1 || !fpString2) return 0;
71
+
72
+ const parse = (str) => {
73
+ const map = new Map();
74
+ str.split("|").forEach((part) => {
75
+ const [k, v] = part.split(":");
76
+ if (k && v) map.set(k, v);
77
+ });
78
+ return map;
79
+ };
80
+
81
+ const map1 = parse(fpString1);
82
+ const map2 = parse(fpString2);
83
+
84
+ // Poids de "véracité" (Entropie/Stabilité)
85
+ 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
93
+ };
94
+
95
+ let weightedMatches = 0;
96
+ let totalWeight = 0;
97
+
98
+ const allKeys = new Set([...map1.keys(), ...map2.keys()]);
99
+
100
+ allKeys.forEach((key) => {
101
+ if (map1.has(key) && map2.has(key)) {
102
+ const weight = weights[key] || 1.0;
103
+ totalWeight += weight;
104
+
105
+ if (map1.get(key) === map2.get(key)) {
106
+ weightedMatches += weight;
107
+ }
108
+ }
109
+ });
110
+
111
+ return totalWeight === 0 ? 0 : weightedMatches / totalWeight;
112
+ }
113
+ }
114
+
115
+ // Cache pour éviter de recalculer les constantes (Hardware, etc.)
116
+ let cachedBuilder = null;
117
+
118
+ /**
119
+ * Génère l'empreinte de l'appareil actuel.
120
+ */
121
+ export const getDeviceFingerprint = () => {
122
+ // NOTE: This is client-side code and should be in a separate file.
123
+ // It will not work in a Node.js environment.
124
+ // The presence of `window` and `document` confirms this.
125
+
126
+ if (typeof window === "undefined") return "server-side";
127
+
128
+ if (!cachedBuilder) {
129
+ const nav = window.navigator;
130
+ const screen = window.screen;
131
+
132
+ cachedBuilder = new FingerprintBuilder();
133
+
134
+ // 1. Hardware (Très stable) : Cœurs, RAM, GPU (si dispo via canvas), Touch
135
+ cachedBuilder.add(
136
+ "hw",
137
+ `${nav.hardwareConcurrency}_${nav.deviceMemory}_${nav.maxTouchPoints}`,
138
+ );
139
+
140
+ // 2. Geo/Locale (Stable sauf voyage/VPN) : Timezone, Langue
141
+ cachedBuilder.add(
142
+ "geo",
143
+ `${Intl.DateTimeFormat().resolvedOptions().timeZone}_${nav.language}_${new Date().getTimezoneOffset()}`,
144
+ );
145
+
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
148
+ cachedBuilder.add(
149
+ "scr",
150
+ `${screen.width}x${screen.height}_${screen.colorDepth}`,
151
+ );
152
+
153
+ // 4. Platform (Stable) : OS, Engine
154
+ cachedBuilder.add("os", nav.platform);
155
+
156
+ // 5. Graphics (WebGL Vendor/Renderer) - Invariant matériel fort
157
+ try {
158
+ const canvas = document.createElement("canvas");
159
+ const gl =
160
+ canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
161
+ if (gl) {
162
+ const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
163
+ if (debugInfo) {
164
+ const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
165
+ const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
166
+ cachedBuilder.add("gpu", `${vendor}_${renderer}`);
167
+ }
168
+ }
169
+ } catch (e) {}
170
+
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
173
+ try {
174
+ const canvas = document.createElement("canvas");
175
+ const ctx = canvas.getContext("2d");
176
+ if (ctx) {
177
+ canvas.width = 200;
178
+ canvas.height = 50;
179
+ ctx.textBaseline = "alphabetic";
180
+ ctx.font = "14px 'Arial'";
181
+ ctx.fillStyle = "#f60";
182
+ ctx.fillRect(125, 1, 62, 20);
183
+ ctx.fillStyle = "#069";
184
+ ctx.fillText("Primals", 2, 15);
185
+ ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
186
+ ctx.fillText("Primals", 4, 17);
187
+ cachedBuilder.add("cvs", canvas.toDataURL());
188
+ }
189
+ } catch (e) {}
190
+
191
+ // 7. Bot Detection (Indication cachée)
192
+ if (nav.webdriver) cachedBuilder.add("bot", "true");
193
+ }
194
+
195
+ // On retourne une copie pour permettre d'ajouter des champs dynamiques si besoin sans polluer le cache
196
+ return cachedBuilder.toString();
197
+ };
198
+
199
+ /**
200
+ * Génère une signature de requête incluant le contexte.
201
+ * @param {object} payload
202
+ */
203
+ export const generateRequestSignature = (payload = {}) => {
204
+ const deviceFp = getDeviceFingerprint();
205
+
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
208
+ const sortedPayload = Object.keys(payload)
209
+ .sort()
210
+ .map((k) => `${k}=${payload[k]}`)
211
+ .join("&");
212
+ const payloadHash = cyrb53(sortedPayload);
213
+
214
+ return `${deviceFp}|req:${payloadHash}`;
215
+ };
216
+
217
+ /**
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.
222
+ */
223
+ export const generateCombatSignature = async (payload, secret) => {
224
+ // NOTE: This is client-side code using the Web Crypto API (`window.crypto`).
225
+ // It should be moved to a client-side script file.
226
+
227
+ // 1. Créer une chaîne de caractères stable à partir du payload.
228
+ const sortedPayload = Object.keys(payload)
229
+ .sort()
230
+ .map((k) => `${k}=${payload[k]}`)
231
+ .join("&");
232
+
233
+ // 2. Utiliser l'API Web Crypto pour le HMAC
234
+ const encoder = new TextEncoder();
235
+ const key = await window.crypto.subtle.importKey(
236
+ "raw",
237
+ encoder.encode(secret),
238
+ { name: "HMAC", hash: "SHA-256" },
239
+ false,
240
+ ["sign"],
241
+ );
242
+ const signatureBuffer = await window.crypto.subtle.sign(
243
+ "HMAC",
244
+ key,
245
+ encoder.encode(sortedPayload),
246
+ );
247
+
248
+ // 3. Convertir la signature en chaîne hexadécimale.
249
+ const hashArray = Array.from(new Uint8Array(signatureBuffer));
250
+ const hexString = hashArray
251
+ .map((b) => b.toString(16).padStart(2, "0"))
252
+ .join("");
253
+ return hexString;
254
+ };
255
+
256
+ /**
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.
264
+ */
265
+ const generateTspChallenge = (
266
+ nonce,
267
+ numCities,
268
+ targetMaxDistance,
269
+ cities,
270
+ path = "",
271
+ ) => {
272
+ const citiesJson = JSON.stringify(cities);
273
+ return `
274
+ <html>
275
+ <head><title>Vérification de sécurité Avancée (Niveau 3)</title></head>
276
+ <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>
280
+ <script>
281
+ const cities = ${citiesJson};
282
+ const nonce = "${nonce}";
283
+ const targetMaxDistance = ${targetMaxDistance};
284
+
285
+ // Fonction utilitaire pour calculer la distance entre deux villes
286
+ function distance(city1, city2) {
287
+ return Math.sqrt(Math.pow(city1.x - city2.x, 2) + Math.pow(city1.y - city2.y, 2));
288
+ }
289
+
290
+ // Fonction utilitaire pour évaluer la distance totale d'un chemin
291
+ function evaluatePathDistance(cities, path) {
292
+ let totalDistance = 0;
293
+ for (let i = 0; i < path.length - 1; i++) {
294
+ totalDistance += distance(cities[path[i]], cities[path[i + 1]]);
295
+ }
296
+ totalDistance += distance(cities[path[path.length - 1]], cities[path[0]]); // Retour au départ
297
+ return totalDistance;
298
+ }
299
+
300
+ // Solveur simple du TSP (heuristique du plus proche voisin)
301
+ function solveTspNearestNeighbor(cities) {
302
+ const numCities = cities.length;
303
+ if (numCities === 0) return [];
304
+
305
+ let currentPath = [];
306
+ let visited = new Array(numCities).fill(false);
307
+
308
+ let currentCityIndex = 0; // Toujours commencer par la première ville pour la reproductibilité
309
+ currentPath.push(currentCityIndex);
310
+ visited[currentCityIndex] = true;
311
+
312
+ for (let i = 1; i < numCities; i++) {
313
+ let nearestCityIndex = -1;
314
+ let minDistance = Infinity;
315
+
316
+ for (let j = 0; j < numCities; j++) {
317
+ if (!visited[j]) {
318
+ const dist = distance(cities[currentCityIndex], cities[j]);
319
+ if (dist < minDistance) {
320
+ minDistance = dist;
321
+ nearestCityIndex = j;
322
+ }
323
+ }
324
+ }
325
+ currentCityIndex = nearestCityIndex;
326
+ currentPath.push(currentCityIndex);
327
+ visited[currentCityIndex] = true;
328
+ }
329
+ return currentPath;
330
+ }
331
+
332
+ async function solve() {
333
+ // Pour ne pas freezer le navigateur, on yield le thread de temps en temps
334
+ await new Promise(resolve => setTimeout(resolve, 10));
335
+ const solutionPath = solveTspNearestNeighbor(cities);
336
+ const solutionDistance = evaluatePathDistance(cities, solutionPath);
337
+
338
+ if (solutionDistance <= targetMaxDistance) {
339
+ window.location.href = "${path}" + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(solutionPath);
340
+ } else {
341
+ document.getElementById('loader').innerText = "Erreur: Impossible de trouver une solution suffisante. Veuillez réessayer.";
342
+ }
343
+ }
344
+ solve();
345
+ </script>
346
+ </body>
347
+ </html>`;
348
+ };
349
+
350
+ /**
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.
358
+ */
359
+ export const verifyTspChallenge = (
360
+ nonce,
361
+ solutionPathJson,
362
+ numCities,
363
+ targetMaxDistance,
364
+ cities,
365
+ ) => {
366
+ try {
367
+ const solutionPath = JSON.parse(solutionPathJson);
368
+ if (!Array.isArray(solutionPath) || solutionPath.length !== numCities)
369
+ return false;
370
+
371
+ // Vérifier que le chemin est une permutation valide des villes
372
+ const uniqueCities = new Set(solutionPath);
373
+ if (
374
+ uniqueCities.size !== numCities ||
375
+ Math.min(...solutionPath) < 0 ||
376
+ Math.max(...solutionPath) >= numCities
377
+ )
378
+ return false;
379
+
380
+ // Recalculer la distance côté serveur
381
+ let totalDistance = 0;
382
+ let totalPenalty = 0;
383
+
384
+ // Fonction pour calculer l'angle entre 3 points (p1 -> p2 -> p3)
385
+ const calculateAngle = (p1, p2, p3) => {
386
+ const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
387
+ const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
388
+ const dotProduct = v1.x * v2.x + v1.y * v2.y;
389
+ const mag1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
390
+ const mag2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
391
+ if (mag1 === 0 || mag2 === 0) return 180;
392
+ const angleRad = Math.acos(dotProduct / (mag1 * mag2));
393
+ return angleRad * (180 / Math.PI);
394
+ };
395
+
396
+ for (let i = 0; i < solutionPath.length; i++) {
397
+ const p1_idx = solutionPath[i];
398
+ const p2_idx = solutionPath[(i + 1) % numCities];
399
+ const p3_idx = solutionPath[(i + 2) % numCities];
400
+
401
+ // 1. Calcul de la distance du segment
402
+ 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
+ // 2. Calcul de la pénalité de virage
405
+ const angle = calculateAngle(
406
+ cities[p1_idx],
407
+ cities[p2_idx],
408
+ cities[p3_idx],
409
+ );
410
+ 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
413
+ }
414
+ }
415
+
416
+ const finalScore = totalDistance + totalPenalty;
417
+ return finalScore <= targetMaxDistance;
418
+ } catch (e) {
419
+ console.error("Erreur lors de la vérification du challenge TSP:", e);
420
+ return false;
421
+ }
422
+ };
423
+
424
+ /**
425
+ * Génère le contenu HTML pour le challenge PoW CPU (SHA-256).
426
+ */
427
+ const generateCpuPoWChallenge = (
428
+ clientIp,
429
+ nonce,
430
+ difficulty = 4,
431
+ path = "",
432
+ ) => {
433
+ return `
434
+ <html>
435
+ <head><title>Vérification de sécurité</title></head>
436
+ <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>
440
+ <script>
441
+ async function solve() {
442
+ const ip = "${clientIp}";
443
+ const nonce = "${nonce}";
444
+ const diff = ${difficulty};
445
+ const target = "0".repeat(diff);
446
+ let solution = 0;
447
+
448
+ while (true) {
449
+ const msg = "${ip}" + ":" + "${nonce}" + ":" + solution;
450
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
451
+ const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
452
+ if (hash.startsWith(target)) break;
453
+ solution++;
454
+ if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // Pour ne pas freezer le navigateur
455
+ }
456
+ window.location.href = "${path}" + "?pow_type=cpu&pow_nonce=" + nonce + "&pow_solution=" + solution;
457
+ }
458
+ solve();
459
+ </script>
460
+ </body>
461
+ </html>
462
+ `;
463
+ };
464
+
465
+ /**
466
+ * Génère le contenu HTML pour un challenge PoW gourmand en mémoire.
467
+ */
468
+ const generateMemoryPoWChallenge = (
469
+ clientIp,
470
+ nonce,
471
+ difficulty = 16,
472
+ path = "",
473
+ ) => {
474
+ // difficulty ici est la taille du buffer en Mo.
475
+ return `
476
+ <html>
477
+ <head><title>Vérification de sécurité Avancée</title></head>
478
+ <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>
482
+ <script>
483
+ async function solve() {
484
+ const nonce = "${nonce}";
485
+ const size = ${difficulty} * 1024 * 1024; // en octets
486
+ const iterations = size / 16;
487
+
488
+ try {
489
+ const buffer = new Uint32Array(size / 4);
490
+ let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
491
+ for (let i = 0; i < buffer.length; i++) {
492
+ buffer[i] = (h = Math.imul(h ^ i, 1597334677));
493
+ }
494
+
495
+ let finalHash = 0;
496
+ for(let i = 0; i < iterations; i++) {
497
+ const addr = buffer[i % buffer.length] % buffer.length;
498
+ finalHash ^= buffer[addr];
499
+ }
500
+ window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
501
+ } catch(e) {
502
+ document.getElementById('loader').innerText = "Erreur: Mémoire insuffisante. Veuillez rafraîchir.";
503
+ }
504
+ }
505
+ solve();
506
+ </script>
507
+ </body>
508
+ </html>`;
509
+ };
510
+
511
+ /**
512
+ * Vérifie si une solution PoW est valide et génère un ticket de passage.
513
+ */
514
+ export const verifyPoWAndGenerateTicket = (
515
+ ip,
516
+ nonce,
517
+ solution,
518
+ difficulty = 4,
519
+ ) => {
520
+ // 1. Vérifier la solution : hash(ip + nonce + solution) doit commencer par N zéros
521
+ const hash = crypto
522
+ .createHash("sha256")
523
+ .update(`${ip}:${nonce}:${solution}`)
524
+ .digest("hex");
525
+
526
+ if (!hash.startsWith("0".repeat(difficulty))) {
527
+ return null;
528
+ }
529
+
530
+ // 2. Générer un ticket HMAC pour que le client n'ait plus à le refaire pendant 1h
531
+ const expiry = Date.now() + 3600000; // 1 heure
532
+ const signature = crypto
533
+ .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
534
+ .update(`${ip}:${expiry}`)
535
+ .digest("hex");
536
+
537
+ return `${expiry}:${signature}`;
538
+ };
539
+
540
+ /**
541
+ * Vérifie une solution de PoW mémoire.
542
+ * Le serveur refait le même calcul pour valider.
543
+ */
544
+ export const verifyMemoryPoW = (nonce, solution, difficulty = 16) => {
545
+ const size = difficulty * 1024 * 1024;
546
+ const iterations = size / 16;
547
+ const buffer = new Uint32Array(size / 4);
548
+ let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
549
+ for (let i = 0; i < buffer.length; i++) {
550
+ buffer[i] = h = Math.imul(h ^ i, 1597334677);
551
+ }
552
+ let finalHash = 0;
553
+ for (let i = 0; i < iterations; i++) {
554
+ const addr = buffer[i % buffer.length] % buffer.length;
555
+ finalHash ^= buffer[addr];
556
+ }
557
+ return finalHash === parseInt(solution, 10);
558
+ };
559
+ export const isTicketValid = (ip, ticket) => {
560
+ if (!ticket) return false;
561
+ const [expiry, sig] = ticket.split(":");
562
+ if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
563
+ const expectedSig = crypto
564
+ .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
565
+ .update(`${ip}:${expiry}`)
566
+ .digest("hex");
567
+
568
+ // Utilisation de timingSafeEqual pour éviter les attaques temporelles
569
+ return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig));
570
+ };
571
+
572
+ /**
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.
577
+ */
578
+ function getDeviceHash(req) {
579
+ const srv = new FingerprintBuilder();
580
+ srv.add("ua", req.headers["user-agent"]);
581
+ if (req.headers["sec-ch-ua-platform"])
582
+ srv.add("os", req.headers["sec-ch-ua-platform"]);
583
+ 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
+ }
586
+
587
+ /**
588
+ * Calcule les indicateurs de suspicion liés aux anomalies des headers HTTP.
589
+ * @param {object} req - L'objet de la requête Express.
590
+ * @returns {{headerAnomalyScore: number}}
591
+ */
592
+ function getHeaderAnomalies(req, consistencyScore) {
593
+ // FIX: consistencyScore est maintenant passé
594
+ let anomalyScore = 0;
595
+ // Pénalité forte si le User-Agent est manquant ou très court (signe de script simple)
596
+ if (!req.headers["user-agent"] || req.headers["user-agent"].length < 10) {
597
+ anomalyScore += 60;
598
+ }
599
+ // Pénalité si le header Accept-Language est manquant
600
+ if (!req.headers["accept-language"]) {
601
+ anomalyScore += 25;
602
+ }
603
+ // Pénalité pour les requêtes HTTP/1.0, souvent utilisées par des outils anciens ou des bots
604
+ if (req.httpVersion === "1.0") {
605
+ anomalyScore += 15;
606
+ }
607
+
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.
611
+ const inconsistencyScore = Math.max(0, (1 - consistencyScore) * 200);
612
+
613
+ return {
614
+ headerAnomalyScore: Math.min(100, anomalyScore),
615
+ inconsistencyScore: Math.min(100, inconsistencyScore),
616
+ };
617
+ }
618
+
619
+ /**
620
+ * @typedef {object} IStore
621
+ * @property {(key: string) => Promise<any>} get
622
+ * @property {(key: string, value: any) => Promise<void>} set
623
+ * @property {(key: string) => Promise<boolean>} has
624
+ * @property {(key: string) => Promise<void>} delete
625
+ */
626
+
627
+ /**
628
+ * Implémentation par défaut du store, en mémoire.
629
+ * @type {IStore}
630
+ */
631
+ const inMemoryStore = {
632
+ _map: new Map(),
633
+ async get(key) { return this._map.get(key); },
634
+ async set(key, value) { this._map.set(key, value); },
635
+ async has(key) { return this._map.has(key); },
636
+ async delete(key) { this._map.delete(key); },
637
+ };
638
+
639
+ /** @type {IStore} */
640
+ let store = inMemoryStore;
641
+
642
+ /**
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.
646
+ */
647
+ export const configureStore = (externalStore) => {
648
+ store = externalStore;
649
+ };
650
+
651
+ /**
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}>}
657
+ */
658
+ async function resolveRequestIdentity(req, res) {
659
+ const existingDeviceId = req.cookies?.device_id;
660
+ const currentDeviceHash = getDeviceHash(req);
661
+ let deviceId = existingDeviceId;
662
+ let consistencyScore = 1.0; // 1.0 = parfaitement cohérent
663
+ let deviceData = null;
664
+
665
+ if (deviceId) {
666
+ deviceData = await store.get(`device:${deviceId}`);
667
+ }
668
+
669
+ if (deviceData) {
670
+ // Cas 1: L'utilisateur a un "passeport" et nous le connaissons.
671
+ const storedHash = deviceData.initialDeviceHash;
672
+
673
+ // Comparaison de l'empreinte actuelle avec celle de référence.
674
+ consistencyScore = FingerprintBuilder.compare(
675
+ storedHash,
676
+ currentDeviceHash,
677
+ );
678
+ } else {
679
+ // Cas 2: Nouvel utilisateur ou cookie perdu/invalide.
680
+ deviceId = crypto.randomUUID(); // On génère un nouveau "passeport".
681
+
682
+ // On pose le cookie de manière sécurisée.
683
+ res.cookie("device_id", deviceId, {
684
+ httpOnly: true,
685
+ secure: process.env.NODE_ENV === "production",
686
+ sameSite: "strict",
687
+ maxAge: 31536000000, // 1 an
688
+ });
689
+
690
+ // On initialise le suivi pour ce nouvel appareil.
691
+ deviceData = {
692
+ initialDeviceHash: currentDeviceHash, // On ancre l'empreinte initiale.
693
+ ips: new Set(),
694
+ lastUpdate: Date.now(),
695
+ lastFpHash: currentDeviceHash,
696
+ lastChangeTimestamp: 0,
697
+ rapidChangeCount: 0,
698
+ };
699
+ // L'écriture se fera dans getSuspicionVector après toutes les modifications.
700
+ }
701
+
702
+ return { deviceId, deviceData, consistencyScore };
703
+ }
704
+
705
+ /*
706
+ * 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.
709
+ * @returns {Promise<{historyScore: number, rotationScore: number}>}
710
+ */
711
+ async function getBehavioralIndicators(req, deviceData) {
712
+ const now = Date.now();
713
+ const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
714
+
715
+ // On récupère le type d'IP pour moduler le score
716
+ const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
717
+ const isSharedIp = ipProfile.type === "shared";
718
+
719
+ const currentFpHash = getDeviceHash(req); // On utilise le hash de l'appareil
720
+
721
+ // --- Analyse de comportement (Fréquence de changement) ---
722
+ if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
723
+ const timeSinceLastChange = now - deviceData.lastChangeTimestamp;
724
+
725
+ if (timeSinceLastChange < RAPID_CHANGE_THRESHOLD_MS) {
726
+ deviceData.rapidChangeCount = Math.min(
727
+ deviceData.rapidChangeCount + 1,
728
+ MAX_RAPID_CHANGES_PER_DEVICE * 2,
729
+ ); // Augmente rapidement
730
+ } else {
731
+ deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Diminue lentement
732
+ }
733
+ deviceData.lastChangeTimestamp = now;
734
+ }
735
+
736
+ deviceData.lastFpHash = currentFpHash;
737
+ deviceData.ips.add(clientIp); // On enregistre l'IP utilisée par cet appareil
738
+
739
+ // NOUVELLE LOGIQUE : Le score d'historique est basé sur le nombre d'IPs utilisées par l'appareil.
740
+ // Très efficace contre la rotation de proxy.
741
+ const maxIpsForDevice = isSharedIp
742
+ ? MAX_DISTINCT_IPS_FOR_SHARED_USER
743
+ : MAX_DISTINCT_IPS_PER_DEVICE;
744
+ const freeIpChanges = isSharedIp ? 1 : 3;
745
+
746
+ const historyScore = Math.min(
747
+ 100,
748
+ (Math.max(0, deviceData.ips.size - freeIpChanges) /
749
+ (maxIpsForDevice - freeIpChanges)) *
750
+ 100,
751
+ );
752
+
753
+ // Score basé sur la rotation rapide d'identité (0-100)
754
+ const rotationScore = Math.min(
755
+ 100,
756
+ (deviceData.rapidChangeCount / MAX_RAPID_CHANGES_PER_DEVICE) * 100,
757
+ );
758
+
759
+ return { historyScore, rotationScore };
760
+ }
761
+
762
+ /**
763
+ * Retourne un vecteur de scores de suspicion bruts (non pondérés).
764
+ * @param {object} req - L'objet de la requête Express.
765
+ * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number}>}
766
+ */
767
+ export const getSuspicionVector = async (req, res) => {
768
+ const { deviceId, deviceData, consistencyScore } = await resolveRequestIdentity(req, res);
769
+
770
+ const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
771
+ await store.set(`ip-device:${clientIp}`, deviceId); // On lie l'IP à l'appareil
772
+
773
+ // Nettoyage périodique des données de l'appareil
774
+ if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
775
+ deviceData.ips.clear();
776
+ deviceData.rapidChangeCount = 0;
777
+ }
778
+ deviceData.lastUpdate = Date.now();
779
+
780
+ const behavioral = await getBehavioralIndicators(req, deviceData);
781
+ const anomalies = getHeaderAnomalies(req, consistencyScore);
782
+
783
+ // Sauvegarde l'état mis à jour de l'appareil dans le store
784
+ await store.set(`device:${deviceId}`, deviceData);
785
+
786
+ return { ...behavioral, ...anomalies };
787
+ };
788
+
789
+ // Un utilisateur résidentiel peut changer de réseau (maison, 4G, wifi public).
790
+ const MAX_DISTINCT_IPS_PER_DEVICE = 15;
791
+ // Un utilisateur derrière un NAT/proxy ne devrait pas utiliser BEAUCOUP d'autres IPs.
792
+ const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
793
+
794
+ // Une IP est considérée comme "partagée" si elle est utilisée par plus de 50 appareils différents en 10 minutes.
795
+ const SHARED_IP_DEVICE_THRESHOLD = 50;
796
+
797
+ 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
+
800
+ /**
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).
804
+ */
805
+ export const identifyRequest = async (req, res) => {
806
+ const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
807
+ const deviceId = req.cookies?.device_id;
808
+
809
+ // --- Mise à jour de la réputation de l'IP ---
810
+ const ipProfile = (await store.get(`ip:${clientIp}`)) || {
811
+ type: "residential",
812
+ deviceIds: new Set(),
813
+ statelessCount: 0,
814
+ lastSeen: 0,
815
+ };
816
+ ipProfile.lastSeen = Date.now();
817
+ if (deviceId) {
818
+ ipProfile.deviceIds.add(deviceId);
819
+ } else {
820
+ // Logique anti "Bot Amnésique" améliorée
821
+ ipProfile.statelessCount++;
822
+ }
823
+
824
+ // Si une IP voit trop d'appareils différents, on la classe comme "partagée".
825
+ if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
826
+ ipProfile.type = "shared";
827
+ }
828
+
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.
831
+ const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
832
+ if (ipProfile.statelessCount > statelessLimit) {
833
+ return `suspicious_high:${clientIp}`;
834
+ }
835
+ await store.set(`ip:${clientIp}`, ipProfile);
836
+
837
+ // Pour la compatibilité avec le rate-limiter, on calcule un score simple.
838
+ // Le PoW utilisera le système pondéré, plus complexe.
839
+ const vector = await getSuspicionVector(req, res);
840
+ const score =
841
+ vector.historyScore * 0.3 +
842
+ vector.rotationScore * 0.5 +
843
+ vector.headerAnomalyScore * 0.1 +
844
+ vector.inconsistencyScore * 0.8; // L'incohérence est un signal très fort
845
+
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.
849
+ if (score >= 75) {
850
+ return `suspicious_high:${clientIp}`;
851
+ }
852
+ if (score >= 40) {
853
+ return `suspicious_medium:${clientIp}`;
854
+ }
855
+
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.
858
+ const deviceIdForIp = await store.get(`ip-device:${clientIp}`);
859
+ const finalDeviceId = deviceId || deviceIdForIp || clientIp;
860
+ return `device:${finalDeviceId}`;
861
+ };
862
+ // --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
863
+
864
+ // Le plus grand nombre possible avec SHA-256 (2^256 - 1)
865
+ const MAX_DIFFICULTY_TARGET = 2n ** 256n - 1n;
866
+ // Une difficulté de base, ex: nécessite que les 16 premiers bits soient à 0
867
+ // (équivalent à 4 zéros en hexadécimal)
868
+ const BASE_TARGET = MAX_DIFFICULTY_TARGET >> 16n;
869
+
870
+ /**
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.
874
+ */
875
+ 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
881
+
882
+ // On utilise une interpolation linéaire entre la difficulté min et max.
883
+ const totalDifficultyBits =
884
+ MIN_DIFFICULTY_BITS +
885
+ suspicionFactor * (MAX_DIFFICULTY_BITS - MIN_DIFFICULTY_BITS);
886
+
887
+ // Le target est le max / 2^bits
888
+ return MAX_DIFFICULTY_TARGET >> BigInt(Math.floor(totalDifficultyBits));
889
+ }
890
+
891
+ /**
892
+ * Génère un challenge CPU basé sur un target.
893
+ */
894
+ export function generateCpuTargetChallenge(
895
+ clientIp,
896
+ nonce,
897
+ suspicionFactor,
898
+ originalUrl,
899
+ ) {
900
+ const target = calculateTarget(suspicionFactor);
901
+ return {
902
+ type: "cpu_target",
903
+ nonce: nonce,
904
+ target: target.toString(16), // On envoie le target en hexadécimal
905
+ path: originalUrl,
906
+ };
907
+ }
908
+
909
+ /**
910
+ * Generates the HTML page for the CPU target challenge.
911
+ * @param {object} challengeDetails - The details from generateCpuTargetChallenge.
912
+ * @param {string} clientIp - The client's IP address.
913
+ * @returns {string} HTML content.
914
+ */
915
+ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
916
+ const { nonce, target, path } = challengeDetails;
917
+ return `
918
+ <html><head><title>Security Check</title></head>
919
+ <body style="font-family:sans-serif; text-align:center; padding-top:50px;">
920
+ <h1>Please wait... (Level 1)</h1>
921
+ <p>We are verifying that you are not a bot. This may take a few seconds.</p>
922
+ <div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
923
+ <script>
924
+ async function solve() {
925
+ const target = BigInt("0x${target}");
926
+ let solution = 0;
927
+ while (true) {
928
+ const msg = "${clientIp}:${nonce}:" + solution;
929
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
930
+ const hashHex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
931
+ if (BigInt('0x' + hashHex) < target) {
932
+ window.location.href = "${path}?pow_type=cpu_target&pow_nonce=${nonce}&pow_solution=" + solution;
933
+ break;
934
+ }
935
+ solution++;
936
+ if (solution % 100000 === 0) await new Promise(r => setTimeout(r, 0));
937
+ }
938
+ }
939
+ solve();
940
+ </script>
941
+ </body></html>`;
942
+ }
943
+
944
+ /**
945
+ * Vérifie une solution de PoW basée sur un target et génère un ticket.
946
+ */
947
+ export function verifyCpuTargetPoWAndGenerateTicket(
948
+ clientIp,
949
+ nonce,
950
+ solution,
951
+ suspicionFactor,
952
+ ) {
953
+ const target = calculateTarget(suspicionFactor);
954
+ const hash = crypto
955
+ .createHash("sha256")
956
+ .update(`${clientIp}:${nonce}:${solution}`)
957
+ .digest("hex");
958
+ const hashAsInt = BigInt("0x" + hash);
959
+
960
+ if (hashAsInt < target) {
961
+ // La comparaison est directe avec les BigInt natifs
962
+ // La preuve est valide, on génère le ticket
963
+ const expiry = Date.now() + 3600000; // 1 heure
964
+ const signature = crypto
965
+ .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
966
+ .update(`${clientIp}:${expiry}`)
967
+ .digest("hex");
968
+ return `${expiry}:${signature}`;
969
+ }
970
+
971
+ return null;
972
+ }
973
+
974
+ const staticExtensions =
975
+ /\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map)$/i;
976
+ const isStaticResource = (req) => staticExtensions.test(req.path);
977
+
978
+ // --- 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
+
985
+ const isProduction = process.env.NODE_ENV === 'production';
986
+ const { weights, thresholds } = securityConfig;
987
+
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);
991
+
992
+ const finalScore =
993
+ suspicionVector.historyScore * weights.historyScore +
994
+ suspicionVector.rotationScore * weights.rotationScore +
995
+ suspicionVector.headerAnomalyScore * weights.headerAnomalyScore +
996
+ suspicionVector.inconsistencyScore * weights.inconsistencyScore;
997
+
998
+ const isSuspiciousHigh = finalScore >= thresholds.high;
999
+ const isSuspiciousMedium = finalScore >= thresholds.medium;
1000
+ const isSuspicious = finalScore >= thresholds.low;
1001
+
1002
+ // Calcul d'un "facteur de suspicion" analogique (0 à 1+) pour une difficulté progressive
1003
+ const suspicionFactor = isSuspicious
1004
+ ? Math.min(
1005
+ 1,
1006
+ (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
1007
+ )
1008
+ : 0;
1009
+ const powCookie = req.cookies?.pow_clearance;
1010
+ const { pow_type, pow_nonce, pow_solution, captcha_token } = req.query;
1011
+
1012
+ if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
1013
+ // --- GESTION DES RÉPONSES AUX CHALLENGES ---
1014
+ if (pow_nonce && pow_solution) {
1015
+ let isValid = false,
1016
+ ticket = null;
1017
+ if (pow_type === "cpu_target") {
1018
+ // On vérifie le nouveau type
1019
+ ticket = verifyCpuTargetPoWAndGenerateTicket(
1020
+ clientIp,
1021
+ pow_nonce,
1022
+ pow_solution,
1023
+ suspicionFactor, // On passe directement le facteur analogique
1024
+ );
1025
+ isValid = ticket !== null;
1026
+ } else if (pow_type === "mem") {
1027
+ const minDifficulty = 16; // 16Mo
1028
+ const maxDifficulty = 48; // 48Mo
1029
+ const difficulty =
1030
+ minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
1031
+ isValid = verifyMemoryPoW(pow_nonce, pow_solution, difficulty);
1032
+ } else if (pow_type === "tsp") {
1033
+ // La logique pour TSP reste la même
1034
+ // ...
1035
+ }
1036
+
1037
+ if (isValid) {
1038
+ if (!ticket) {
1039
+ // Si le ticket n'a pas déjà été généré (cas CPU)
1040
+ const expiry = Date.now() + 3600000; // 1 heure
1041
+ const signature = crypto
1042
+ .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
1043
+ .update(`${clientIp}:${expiry}`)
1044
+ .digest("hex");
1045
+ ticket = `${expiry}:${signature}`;
1046
+ }
1047
+
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
1054
+ }
1055
+ }
1056
+
1057
+ // --- SÉLECTION ET ENVOI DU CHALLENGE APPROPRIÉ ---
1058
+ const nonce = crypto.randomBytes(16).toString("hex");
1059
+
1060
+ // NIVEAU 3 : CAPTCHA (le plus élevé)
1061
+ if (isSuspiciousHigh) {
1062
+ // ... logique pour le challenge TSP/Captcha
1063
+ }
1064
+
1065
+ // NIVEAU 2 : PoW Gourmand en Mémoire
1066
+ 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
+ );
1076
+ }
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);
1089
+ }
1090
+ }
1091
+ next();
1092
+ };
1093
+
1094
+ /**
1095
+ * @internal
1096
+ * Exporting an object containing the functions to make them mockable in tests.
1097
+ * This is a common pattern to allow mocking of ES module functions.
1098
+ */
1099
+ export const __internal = {
1100
+ getSuspicionVector,
1101
+ calculateTarget,
1102
+ };