@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/LICENSE +21 -0
- package/README.md +170 -0
- package/fingerprint.js +1102 -0
- package/library.js +2270 -0
- package/package.json +46 -0
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
|
+
};
|