@anonympins/fingerprint 0.0.1 → 0.0.3
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/README.md +175 -44
- package/fingerprint.js +565 -504
- package/library.js +1 -825
- package/package.json +5 -4
package/fingerprint.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// C:/Dev/games.primals.net/src/utils/fingerprint.js
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import { Optimization } from "./library.js";
|
|
4
|
+
import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
|
|
5
|
+
import { getDeviceHash } from "./fingerprint.server.js";
|
|
3
6
|
|
|
4
7
|
const POW_SECRET = process.env.POW_SECRET;
|
|
5
8
|
|
|
@@ -8,259 +11,15 @@ if (!POW_SECRET && process.env.NODE_ENV === 'production') {
|
|
|
8
11
|
} else if (!POW_SECRET) {
|
|
9
12
|
console.warn('Warning: POW_SECRET environment variable not set. Using a default, insecure secret for development.');
|
|
10
13
|
}
|
|
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
14
|
|
|
118
15
|
/**
|
|
119
|
-
*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
16
|
+
* Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
|
|
17
|
+
* @param {string} nonce - Unique nonce for the challenge.
|
|
18
|
+
* @param {number} numCities - Number of cities to include in the problem.
|
|
19
|
+
* @param {number} targetMaxDistance - Maximum acceptable distance for the solution.
|
|
20
|
+
* @param {Array<{x: number, y: number}>} cities - Coordinates of the cities.
|
|
21
|
+
* @param {string} path - Redirect path after solving.
|
|
22
|
+
* @returns {string} HTML of the challenge page.
|
|
264
23
|
*/
|
|
265
24
|
const generateTspChallenge = (
|
|
266
25
|
nonce,
|
|
@@ -272,28 +31,28 @@ const generateTspChallenge = (
|
|
|
272
31
|
const citiesJson = JSON.stringify(cities);
|
|
273
32
|
return `
|
|
274
33
|
<html>
|
|
275
|
-
<head><title>
|
|
34
|
+
<head><title>Advanced Security Check (Level 3)</title></head>
|
|
276
35
|
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
277
|
-
<h1>
|
|
278
|
-
<p>
|
|
279
|
-
<div id="loader" style="margin:20px;">⚙️
|
|
36
|
+
<h1>Ultimate Verification (Level 3)</h1>
|
|
37
|
+
<p>Please solve this small optimization problem to prove you are human.</p>
|
|
38
|
+
<div id="loader" style="margin:20px;">⚙️ Calculating route... (${numCities} cities)</div>
|
|
280
39
|
<script>
|
|
281
40
|
const cities = ${citiesJson};
|
|
282
41
|
const nonce = "${nonce}";
|
|
283
42
|
const targetMaxDistance = ${targetMaxDistance};
|
|
284
43
|
|
|
285
|
-
//
|
|
44
|
+
// Utility function to calculate the distance between two cities
|
|
286
45
|
function distance(city1, city2) {
|
|
287
46
|
return Math.sqrt(Math.pow(city1.x - city2.x, 2) + Math.pow(city1.y - city2.y, 2));
|
|
288
47
|
}
|
|
289
48
|
|
|
290
|
-
//
|
|
49
|
+
// Utility function to evaluate the total distance of a path
|
|
291
50
|
function evaluatePathDistance(cities, path) {
|
|
292
51
|
let totalDistance = 0;
|
|
293
52
|
for (let i = 0; i < path.length - 1; i++) {
|
|
294
53
|
totalDistance += distance(cities[path[i]], cities[path[i + 1]]);
|
|
295
54
|
}
|
|
296
|
-
totalDistance += distance(cities[path[path.length - 1]], cities[path[0]]); //
|
|
55
|
+
totalDistance += distance(cities[path[path.length - 1]], cities[path[0]]); // Return to start
|
|
297
56
|
return totalDistance;
|
|
298
57
|
}
|
|
299
58
|
|
|
@@ -305,7 +64,7 @@ const generateTspChallenge = (
|
|
|
305
64
|
let currentPath = [];
|
|
306
65
|
let visited = new Array(numCities).fill(false);
|
|
307
66
|
|
|
308
|
-
let currentCityIndex = 0; //
|
|
67
|
+
let currentCityIndex = 0; // Always start with the first city for reproducibility
|
|
309
68
|
currentPath.push(currentCityIndex);
|
|
310
69
|
visited[currentCityIndex] = true;
|
|
311
70
|
|
|
@@ -330,7 +89,7 @@ const generateTspChallenge = (
|
|
|
330
89
|
}
|
|
331
90
|
|
|
332
91
|
async function solve() {
|
|
333
|
-
//
|
|
92
|
+
// To avoid freezing the browser, yield the thread from time to time
|
|
334
93
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
335
94
|
const solutionPath = solveTspNearestNeighbor(cities);
|
|
336
95
|
const solutionDistance = evaluatePathDistance(cities, solutionPath);
|
|
@@ -338,7 +97,7 @@ const generateTspChallenge = (
|
|
|
338
97
|
if (solutionDistance <= targetMaxDistance) {
|
|
339
98
|
window.location.href = "${path}" + "?pow_type=tsp&pow_nonce=" + nonce + "&pow_solution=" + JSON.stringify(solutionPath);
|
|
340
99
|
} else {
|
|
341
|
-
document.getElementById('loader').innerText = "
|
|
100
|
+
document.getElementById('loader').innerText = "Error: Could not find a sufficient solution. Please try again.";
|
|
342
101
|
}
|
|
343
102
|
}
|
|
344
103
|
solve();
|
|
@@ -348,13 +107,13 @@ const generateTspChallenge = (
|
|
|
348
107
|
};
|
|
349
108
|
|
|
350
109
|
/**
|
|
351
|
-
*
|
|
352
|
-
* @param {string} nonce -
|
|
353
|
-
* @param {string} solutionPathJson -
|
|
354
|
-
* @param {number} numCities -
|
|
355
|
-
* @param {number} targetMaxDistance -
|
|
356
|
-
* @param {Array<{x: number, y: number}>} cities -
|
|
357
|
-
* @returns {boolean} True
|
|
110
|
+
* Verifies a TSP PoW solution.
|
|
111
|
+
* @param {string} nonce - The challenge nonce.
|
|
112
|
+
* @param {string} solutionPathJson - The path proposed by the client (stringified JSON).
|
|
113
|
+
* @param {number} numCities - The number of cities in the challenge.
|
|
114
|
+
* @param {number} targetMaxDistance - The maximum acceptable distance.
|
|
115
|
+
* @param {Array<{x: number, y: number}>} cities - The coordinates of the cities.
|
|
116
|
+
* @returns {boolean} True if the solution is valid.
|
|
358
117
|
*/
|
|
359
118
|
export const verifyTspChallenge = (
|
|
360
119
|
nonce,
|
|
@@ -368,7 +127,7 @@ export const verifyTspChallenge = (
|
|
|
368
127
|
if (!Array.isArray(solutionPath) || solutionPath.length !== numCities)
|
|
369
128
|
return false;
|
|
370
129
|
|
|
371
|
-
//
|
|
130
|
+
// Verify that the path is a valid permutation of the cities
|
|
372
131
|
const uniqueCities = new Set(solutionPath);
|
|
373
132
|
if (
|
|
374
133
|
uniqueCities.size !== numCities ||
|
|
@@ -377,11 +136,11 @@ export const verifyTspChallenge = (
|
|
|
377
136
|
)
|
|
378
137
|
return false;
|
|
379
138
|
|
|
380
|
-
//
|
|
139
|
+
// Recalculate the distance on the server side
|
|
381
140
|
let totalDistance = 0;
|
|
382
141
|
let totalPenalty = 0;
|
|
383
142
|
|
|
384
|
-
//
|
|
143
|
+
// Function to calculate the angle between 3 points (p1 -> p2 -> p3)
|
|
385
144
|
const calculateAngle = (p1, p2, p3) => {
|
|
386
145
|
const v1 = { x: p1.x - p2.x, y: p1.y - p2.y };
|
|
387
146
|
const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
|
|
@@ -398,31 +157,31 @@ export const verifyTspChallenge = (
|
|
|
398
157
|
const p2_idx = solutionPath[(i + 1) % numCities];
|
|
399
158
|
const p3_idx = solutionPath[(i + 2) % numCities];
|
|
400
159
|
|
|
401
|
-
// 1.
|
|
160
|
+
// 1. Calculate segment distance
|
|
402
161
|
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
162
|
|
|
404
|
-
// 2.
|
|
163
|
+
// 2. Calculate turn penalty
|
|
405
164
|
const angle = calculateAngle(
|
|
406
165
|
cities[p1_idx],
|
|
407
166
|
cities[p2_idx],
|
|
408
167
|
cities[p3_idx],
|
|
409
168
|
);
|
|
410
169
|
if (angle < 45) {
|
|
411
|
-
//
|
|
412
|
-
totalPenalty += (45 - angle) * 5; //
|
|
170
|
+
// Penalty for very sharp turns (< 45 degrees)
|
|
171
|
+
totalPenalty += (45 - angle) * 5; // The penalty is proportional to the sharpness of the angle
|
|
413
172
|
}
|
|
414
173
|
}
|
|
415
174
|
|
|
416
175
|
const finalScore = totalDistance + totalPenalty;
|
|
417
176
|
return finalScore <= targetMaxDistance;
|
|
418
177
|
} catch (e) {
|
|
419
|
-
console.error("
|
|
178
|
+
console.error("Error during TSP challenge verification:", e);
|
|
420
179
|
return false;
|
|
421
180
|
}
|
|
422
181
|
};
|
|
423
182
|
|
|
424
183
|
/**
|
|
425
|
-
*
|
|
184
|
+
* Generates the HTML content for the CPU PoW challenge (SHA-256).
|
|
426
185
|
*/
|
|
427
186
|
const generateCpuPoWChallenge = (
|
|
428
187
|
clientIp,
|
|
@@ -432,11 +191,11 @@ const generateCpuPoWChallenge = (
|
|
|
432
191
|
) => {
|
|
433
192
|
return `
|
|
434
193
|
<html>
|
|
435
|
-
<head><title>
|
|
194
|
+
<head><title>Security Check</title></head>
|
|
436
195
|
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
437
|
-
<h1>
|
|
438
|
-
<p>
|
|
439
|
-
<div id="loader" style="margin:20px;">⚙️
|
|
196
|
+
<h1>One moment... (Level 1)</h1>
|
|
197
|
+
<p>We are verifying that you are not a bot. This takes a few seconds.</p>
|
|
198
|
+
<div id="loader" style="margin:20px;">⚙️ Performing CPU security calculation...</div>
|
|
440
199
|
<script>
|
|
441
200
|
async function solve() {
|
|
442
201
|
const ip = "${clientIp}";
|
|
@@ -451,7 +210,7 @@ const generateCpuPoWChallenge = (
|
|
|
451
210
|
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
452
211
|
if (hash.startsWith(target)) break;
|
|
453
212
|
solution++;
|
|
454
|
-
if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); //
|
|
213
|
+
if (solution % 100000 === 0) await new Promise(resolve => setTimeout(resolve, 0)); // To avoid freezing the browser
|
|
455
214
|
}
|
|
456
215
|
window.location.href = "${path}" + "?pow_type=cpu&pow_nonce=" + nonce + "&pow_solution=" + solution;
|
|
457
216
|
}
|
|
@@ -463,7 +222,7 @@ const generateCpuPoWChallenge = (
|
|
|
463
222
|
};
|
|
464
223
|
|
|
465
224
|
/**
|
|
466
|
-
*
|
|
225
|
+
* Generates the HTML content for a memory-intensive PoW challenge.
|
|
467
226
|
*/
|
|
468
227
|
const generateMemoryPoWChallenge = (
|
|
469
228
|
clientIp,
|
|
@@ -471,14 +230,14 @@ const generateMemoryPoWChallenge = (
|
|
|
471
230
|
difficulty = 16,
|
|
472
231
|
path = "",
|
|
473
232
|
) => {
|
|
474
|
-
// difficulty
|
|
233
|
+
// difficulty here is the buffer size in MB.
|
|
475
234
|
return `
|
|
476
235
|
<html>
|
|
477
|
-
<head><title>
|
|
236
|
+
<head><title>Advanced Security Check</title></head>
|
|
478
237
|
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
479
|
-
<h1>
|
|
480
|
-
<p>
|
|
481
|
-
<div id="loader" style="margin:20px;">⚙️
|
|
238
|
+
<h1>Enhanced Verification... (Level 2)</h1>
|
|
239
|
+
<p>Your activity requires an additional security check.</p>
|
|
240
|
+
<div id="loader" style="margin:20px;">⚙️ Performing memory allocation and calculation... (${difficulty} MB)</div>
|
|
482
241
|
<script>
|
|
483
242
|
async function solve() {
|
|
484
243
|
const nonce = "${nonce}";
|
|
@@ -499,7 +258,7 @@ const generateMemoryPoWChallenge = (
|
|
|
499
258
|
}
|
|
500
259
|
window.location.href = "${path}" + "?pow_type=mem&pow_nonce=" + nonce + "&pow_solution=" + finalHash;
|
|
501
260
|
} catch(e) {
|
|
502
|
-
document.getElementById('loader').innerText = "
|
|
261
|
+
document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
|
|
503
262
|
}
|
|
504
263
|
}
|
|
505
264
|
solve();
|
|
@@ -509,7 +268,7 @@ const generateMemoryPoWChallenge = (
|
|
|
509
268
|
};
|
|
510
269
|
|
|
511
270
|
/**
|
|
512
|
-
*
|
|
271
|
+
* Verifies if a PoW solution is valid and generates a clearance ticket.
|
|
513
272
|
*/
|
|
514
273
|
export const verifyPoWAndGenerateTicket = (
|
|
515
274
|
ip,
|
|
@@ -517,7 +276,7 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
517
276
|
solution,
|
|
518
277
|
difficulty = 4,
|
|
519
278
|
) => {
|
|
520
|
-
// 1.
|
|
279
|
+
// 1. Verify the solution: hash(ip + nonce + solution) must start with N zeros
|
|
521
280
|
const hash = crypto
|
|
522
281
|
.createHash("sha256")
|
|
523
282
|
.update(`${ip}:${nonce}:${solution}`)
|
|
@@ -527,7 +286,7 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
527
286
|
return null;
|
|
528
287
|
}
|
|
529
288
|
|
|
530
|
-
// 2.
|
|
289
|
+
// 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
|
|
531
290
|
const expiry = Date.now() + 3600000; // 1 heure
|
|
532
291
|
const signature = crypto
|
|
533
292
|
.createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
|
|
@@ -538,8 +297,8 @@ export const verifyPoWAndGenerateTicket = (
|
|
|
538
297
|
};
|
|
539
298
|
|
|
540
299
|
/**
|
|
541
|
-
*
|
|
542
|
-
*
|
|
300
|
+
* Verifies a memory PoW solution.
|
|
301
|
+
* The server performs the same calculation to validate.
|
|
543
302
|
*/
|
|
544
303
|
export const verifyMemoryPoW = (nonce, solution, difficulty = 16) => {
|
|
545
304
|
const size = difficulty * 1024 * 1024;
|
|
@@ -565,54 +324,38 @@ export const isTicketValid = (ip, ticket) => {
|
|
|
565
324
|
.update(`${ip}:${expiry}`)
|
|
566
325
|
.digest("hex");
|
|
567
326
|
|
|
568
|
-
//
|
|
569
|
-
|
|
327
|
+
// Use timingSafeEqual to prevent timing attacks
|
|
328
|
+
try {
|
|
329
|
+
return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// This can happen if the buffers have different lengths, which is a failure case.
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
570
334
|
};
|
|
571
335
|
|
|
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
336
|
|
|
587
337
|
/**
|
|
588
|
-
*
|
|
589
|
-
* @param {object}
|
|
338
|
+
* Calculates suspicion indicators related to HTTP header anomalies.
|
|
339
|
+
* @param {object} context - The request context.
|
|
590
340
|
* @returns {{headerAnomalyScore: number}}
|
|
591
341
|
*/
|
|
592
|
-
function getHeaderAnomalies(
|
|
593
|
-
// FIX: consistencyScore est maintenant passé
|
|
342
|
+
function getHeaderAnomalies(context) {
|
|
594
343
|
let anomalyScore = 0;
|
|
595
|
-
//
|
|
596
|
-
if (!
|
|
344
|
+
// Strong penalty if User-Agent is missing or very short (sign of a simple script)
|
|
345
|
+
if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
|
|
597
346
|
anomalyScore += 60;
|
|
598
347
|
}
|
|
599
|
-
//
|
|
600
|
-
if (!
|
|
348
|
+
// Penalty if Accept-Language header is missing
|
|
349
|
+
if (!context.headers["accept-language"]) {
|
|
601
350
|
anomalyScore += 25;
|
|
602
351
|
}
|
|
603
|
-
//
|
|
604
|
-
if (
|
|
352
|
+
// Penalty for HTTP/1.0 requests, often used by old tools or bots
|
|
353
|
+
if (context.httpVersion === "1.0") {
|
|
605
354
|
anomalyScore += 15;
|
|
606
355
|
}
|
|
607
356
|
|
|
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
357
|
return {
|
|
614
358
|
headerAnomalyScore: Math.min(100, anomalyScore),
|
|
615
|
-
inconsistencyScore: Math.min(100, inconsistencyScore),
|
|
616
359
|
};
|
|
617
360
|
}
|
|
618
361
|
|
|
@@ -625,8 +368,8 @@ function getHeaderAnomalies(req, consistencyScore) {
|
|
|
625
368
|
*/
|
|
626
369
|
|
|
627
370
|
/**
|
|
628
|
-
*
|
|
629
|
-
* @type {IStore}
|
|
371
|
+
* Default in-memory store implementation.
|
|
372
|
+
* @type {IStore}
|
|
630
373
|
*/
|
|
631
374
|
const inMemoryStore = {
|
|
632
375
|
_map: new Map(),
|
|
@@ -640,101 +383,102 @@ const inMemoryStore = {
|
|
|
640
383
|
let store = inMemoryStore;
|
|
641
384
|
|
|
642
385
|
/**
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* @param {IStore} externalStore -
|
|
386
|
+
* Allows configuring an external datastore (e.g., Redis).
|
|
387
|
+
* Must be called before the middleware is used.
|
|
388
|
+
* @param {IStore} externalStore - An implementation of the IStore interface.
|
|
646
389
|
*/
|
|
647
390
|
export const configureStore = (externalStore) => {
|
|
648
391
|
store = externalStore;
|
|
649
392
|
};
|
|
650
393
|
|
|
651
394
|
/**
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
* @param {object}
|
|
655
|
-
* @
|
|
656
|
-
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number}>}
|
|
395
|
+
* Orchestrates request identification using a persistent anchor (cookie)
|
|
396
|
+
* and fingerprint verification.
|
|
397
|
+
* @param {object} context - The request context.
|
|
398
|
+
* @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
|
|
657
399
|
*/
|
|
658
|
-
async function resolveRequestIdentity(
|
|
659
|
-
const existingDeviceId =
|
|
660
|
-
const currentDeviceHash = getDeviceHash(
|
|
400
|
+
async function resolveRequestIdentity(context) {
|
|
401
|
+
const existingDeviceId = context.cookies?.device_id;
|
|
402
|
+
const currentDeviceHash = getDeviceHash(context);
|
|
661
403
|
let deviceId = existingDeviceId;
|
|
662
|
-
let consistencyScore = 1.0; // 1.0 =
|
|
404
|
+
let consistencyScore = 1.0; // 1.0 = perfectly consistent
|
|
663
405
|
let deviceData = null;
|
|
406
|
+
let newCookie = null;
|
|
664
407
|
|
|
665
408
|
if (deviceId) {
|
|
666
409
|
deviceData = await store.get(`device:${deviceId}`);
|
|
667
410
|
}
|
|
668
411
|
|
|
669
412
|
if (deviceData) {
|
|
670
|
-
//
|
|
413
|
+
// Case 1: The user has a "passport" and we know them.
|
|
671
414
|
const storedHash = deviceData.initialDeviceHash;
|
|
672
415
|
|
|
673
|
-
//
|
|
416
|
+
// Compare the current fingerprint with the reference one.
|
|
674
417
|
consistencyScore = FingerprintBuilder.compare(
|
|
675
418
|
storedHash,
|
|
676
419
|
currentDeviceHash,
|
|
677
420
|
);
|
|
678
421
|
} else {
|
|
679
|
-
//
|
|
680
|
-
deviceId = crypto.randomUUID(); //
|
|
681
|
-
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
422
|
+
// Case 2: New user or lost/invalid cookie.
|
|
423
|
+
deviceId = crypto.randomUUID(); // Generate a new "passport".
|
|
424
|
+
|
|
425
|
+
// Return the intention to set a cookie.
|
|
426
|
+
newCookie = {
|
|
427
|
+
name: "device_id",
|
|
428
|
+
value: deviceId,
|
|
429
|
+
options: {
|
|
430
|
+
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: 31536000000, // 1 year
|
|
431
|
+
}
|
|
432
|
+
};
|
|
689
433
|
|
|
690
|
-
//
|
|
434
|
+
// Initialize tracking for this new device.
|
|
691
435
|
deviceData = {
|
|
692
|
-
initialDeviceHash: currentDeviceHash, //
|
|
436
|
+
initialDeviceHash: currentDeviceHash, // Anchor the initial fingerprint.
|
|
693
437
|
ips: new Set(),
|
|
694
438
|
lastUpdate: Date.now(),
|
|
695
439
|
lastFpHash: currentDeviceHash,
|
|
696
440
|
lastChangeTimestamp: 0,
|
|
697
441
|
rapidChangeCount: 0,
|
|
698
442
|
};
|
|
699
|
-
//
|
|
443
|
+
// The write will happen in getSuspicionVector after all modifications.
|
|
700
444
|
}
|
|
701
445
|
|
|
702
|
-
return { deviceId, deviceData, consistencyScore };
|
|
446
|
+
return { deviceId, deviceData, consistencyScore, newCookie };
|
|
703
447
|
}
|
|
704
448
|
|
|
705
449
|
/*
|
|
706
450
|
* Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
|
|
707
|
-
* @param {object}
|
|
708
|
-
* @param {object} deviceData -
|
|
451
|
+
* @param {object} context - The request context.
|
|
452
|
+
* @param {object} deviceData - The device's activity data.
|
|
709
453
|
* @returns {Promise<{historyScore: number, rotationScore: number}>}
|
|
710
454
|
*/
|
|
711
|
-
async function getBehavioralIndicators(
|
|
455
|
+
async function getBehavioralIndicators(context, deviceData) {
|
|
712
456
|
const now = Date.now();
|
|
713
|
-
const clientIp =
|
|
457
|
+
const clientIp = context.clientIp;
|
|
714
458
|
|
|
715
|
-
//
|
|
459
|
+
// Get the IP type to modulate the score
|
|
716
460
|
const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
|
|
717
461
|
const isSharedIp = ipProfile.type === "shared";
|
|
718
462
|
|
|
719
|
-
const currentFpHash = getDeviceHash(
|
|
463
|
+
const currentFpHash = getDeviceHash(context); // Use the device hash
|
|
720
464
|
|
|
721
|
-
// ---
|
|
465
|
+
// --- Behavior analysis (Change frequency) ---
|
|
722
466
|
if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
|
|
723
467
|
const timeSinceLastChange = now - deviceData.lastChangeTimestamp;
|
|
724
468
|
|
|
725
469
|
if (timeSinceLastChange < RAPID_CHANGE_THRESHOLD_MS) {
|
|
726
470
|
deviceData.rapidChangeCount = Math.min(
|
|
727
471
|
deviceData.rapidChangeCount + 1,
|
|
728
|
-
MAX_RAPID_CHANGES_PER_DEVICE * 2,
|
|
729
|
-
);
|
|
472
|
+
MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
|
|
473
|
+
);
|
|
730
474
|
} else {
|
|
731
|
-
deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); //
|
|
475
|
+
deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
|
|
732
476
|
}
|
|
733
477
|
deviceData.lastChangeTimestamp = now;
|
|
734
478
|
}
|
|
735
479
|
|
|
736
480
|
deviceData.lastFpHash = currentFpHash;
|
|
737
|
-
deviceData.ips.add(clientIp); //
|
|
481
|
+
deviceData.ips.add(clientIp); // Record the IP used by this device
|
|
738
482
|
|
|
739
483
|
// NOUVELLE LOGIQUE : Le score d'historique est basé sur le nombre d'IPs utilisées par l'appareil.
|
|
740
484
|
// Très efficace contre la rotation de proxy.
|
|
@@ -750,7 +494,7 @@ async function getBehavioralIndicators(req, deviceData) {
|
|
|
750
494
|
100,
|
|
751
495
|
);
|
|
752
496
|
|
|
753
|
-
// Score
|
|
497
|
+
// Score based on rapid identity rotation (0-100)
|
|
754
498
|
const rotationScore = Math.min(
|
|
755
499
|
100,
|
|
756
500
|
(deviceData.rapidChangeCount / MAX_RAPID_CHANGES_PER_DEVICE) * 100,
|
|
@@ -760,33 +504,43 @@ async function getBehavioralIndicators(req, deviceData) {
|
|
|
760
504
|
}
|
|
761
505
|
|
|
762
506
|
/**
|
|
763
|
-
*
|
|
764
|
-
* @param {object}
|
|
507
|
+
* Returns a vector of raw (unweighted) suspicion scores.
|
|
508
|
+
* @param {object} context - The request context object.
|
|
765
509
|
* @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number}>}
|
|
766
510
|
*/
|
|
767
|
-
export const getSuspicionVector = async (
|
|
768
|
-
const { deviceId, deviceData, consistencyScore } = await resolveRequestIdentity(
|
|
511
|
+
export const getSuspicionVector = async (context) => {
|
|
512
|
+
const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
|
|
769
513
|
|
|
770
|
-
const clientIp =
|
|
771
|
-
await store.set(`ip-device:${clientIp}`, deviceId); // On lie l'IP à l'appareil
|
|
514
|
+
const clientIp = context.clientIp;
|
|
772
515
|
|
|
773
|
-
//
|
|
516
|
+
// If a new cookie needs to be set, attach it to the request object
|
|
517
|
+
// so the middleware can handle it. This is a temporary state holder.
|
|
518
|
+
if (newCookie) {
|
|
519
|
+
context._newCookies = context._newCookies || [];
|
|
520
|
+
context._newCookies.push(newCookie);
|
|
521
|
+
}
|
|
522
|
+
await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
|
|
523
|
+
|
|
524
|
+
// Periodically clean up device data
|
|
774
525
|
if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
|
|
775
526
|
deviceData.ips.clear();
|
|
776
527
|
deviceData.rapidChangeCount = 0;
|
|
777
528
|
}
|
|
778
529
|
deviceData.lastUpdate = Date.now();
|
|
779
530
|
|
|
780
|
-
const behavioral = await getBehavioralIndicators(
|
|
781
|
-
const
|
|
531
|
+
const behavioral = await getBehavioralIndicators(context, deviceData);
|
|
532
|
+
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
533
|
+
// Calculate the inconsistency score here, separately.
|
|
534
|
+
const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200));
|
|
535
|
+
|
|
782
536
|
|
|
783
|
-
//
|
|
537
|
+
// Save the updated device state to the store
|
|
784
538
|
await store.set(`device:${deviceId}`, deviceData);
|
|
785
539
|
|
|
786
|
-
return { ...behavioral,
|
|
540
|
+
return { ...behavioral, headerAnomalyScore, inconsistencyScore };
|
|
787
541
|
};
|
|
788
542
|
|
|
789
|
-
//
|
|
543
|
+
// A residential user can change networks (home, 4G, public wifi).
|
|
790
544
|
const MAX_DISTINCT_IPS_PER_DEVICE = 15;
|
|
791
545
|
// Un utilisateur derrière un NAT/proxy ne devrait pas utiliser BEAUCOUP d'autres IPs.
|
|
792
546
|
const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
|
|
@@ -795,101 +549,72 @@ const MAX_DISTINCT_IPS_FOR_SHARED_USER = 5;
|
|
|
795
549
|
const SHARED_IP_DEVICE_THRESHOLD = 50;
|
|
796
550
|
|
|
797
551
|
const RAPID_CHANGE_THRESHOLD_MS = 2000; // 2 secondes
|
|
798
|
-
const MAX_RAPID_CHANGES_PER_DEVICE = 3; //
|
|
552
|
+
const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes allowed per device.
|
|
799
553
|
|
|
800
554
|
/**
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
555
|
+
* Identifies a request on the server side in a granular way.
|
|
556
|
+
* Uses FingerprintBuilder to create a fingerprint based on headers
|
|
557
|
+
* and IP, making spoofing more complex (requires changing the entire stack).
|
|
804
558
|
*/
|
|
805
559
|
export const identifyRequest = async (req, res) => {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
560
|
+
// This function now acts as a lightweight wrapper around the engine's identifyRequest method.
|
|
561
|
+
// It requires a default configuration to work.
|
|
562
|
+
const defaultConfig = {
|
|
563
|
+
weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8 },
|
|
564
|
+
thresholds: { low: 20, medium: 40, high: 75 }
|
|
565
|
+
};
|
|
566
|
+
const engine = new FingerprintEngine(defaultConfig);
|
|
567
|
+
|
|
568
|
+
const requestContext = {
|
|
569
|
+
clientIp: req.ip || req.socket?.remoteAddress || "unknown",
|
|
570
|
+
cookies: req.cookies,
|
|
571
|
+
headers: req.headers,
|
|
572
|
+
rawHeaders: req.rawHeaders,
|
|
573
|
+
httpVersion: req.httpVersion,
|
|
815
574
|
};
|
|
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
575
|
|
|
824
|
-
|
|
825
|
-
if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
|
|
826
|
-
ipProfile.type = "shared";
|
|
827
|
-
}
|
|
576
|
+
const key = await engine.identifyRequest(requestContext);
|
|
828
577
|
|
|
829
|
-
|
|
830
|
-
|
|
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}`;
|
|
578
|
+
if (requestContext._newCookies && res) {
|
|
579
|
+
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
854
580
|
}
|
|
855
581
|
|
|
856
|
-
|
|
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}`;
|
|
582
|
+
return key;
|
|
861
583
|
};
|
|
862
584
|
// --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
|
|
863
585
|
|
|
864
586
|
// Le plus grand nombre possible avec SHA-256 (2^256 - 1)
|
|
587
|
+
// The largest possible number with SHA-256 (2^256 - 1)
|
|
865
588
|
const MAX_DIFFICULTY_TARGET = 2n ** 256n - 1n;
|
|
866
589
|
// Une difficulté de base, ex: nécessite que les 16 premiers bits soient à 0
|
|
867
590
|
// (équivalent à 4 zéros en hexadécimal)
|
|
591
|
+
// A base difficulty, e.g., requires the first 16 bits to be 0
|
|
592
|
+
// (equivalent to 4 zeros in hexadecimal)
|
|
868
593
|
const BASE_TARGET = MAX_DIFFICULTY_TARGET >> 16n;
|
|
869
594
|
|
|
870
595
|
/**
|
|
871
|
-
*
|
|
872
|
-
* @param {number} suspicionFactor -
|
|
873
|
-
* @returns {BigInt}
|
|
596
|
+
* Calculates the difficulty target based on the suspicion factor.
|
|
597
|
+
* @param {number} suspicionFactor - A number from 0 to 1.
|
|
598
|
+
* @returns {BigInt} The target number.
|
|
874
599
|
*/
|
|
875
600
|
function calculateTarget(suspicionFactor) {
|
|
876
|
-
//
|
|
877
|
-
// MIN_DIFFICULTY:
|
|
878
|
-
// MAX_DIFFICULTY:
|
|
879
|
-
const MIN_DIFFICULTY_BITS = 18; //
|
|
880
|
-
const MAX_DIFFICULTY_BITS = 26; //
|
|
601
|
+
// Difficulty range adjusted to be realistic.
|
|
602
|
+
// MIN_DIFFICULTY: Fast enough not to bother a slightly suspicious user.
|
|
603
|
+
// MAX_DIFFICULTY: Slow enough to heavily penalize a bot, but feasible for a patient human (5-30s).
|
|
604
|
+
const MIN_DIFFICULTY_BITS = 18; // Default value, should be configurable
|
|
605
|
+
const MAX_DIFFICULTY_BITS = 26; // Default value, should be configurable
|
|
881
606
|
|
|
882
|
-
//
|
|
607
|
+
// Use linear interpolation between min and max difficulty.
|
|
883
608
|
const totalDifficultyBits =
|
|
884
609
|
MIN_DIFFICULTY_BITS +
|
|
885
610
|
suspicionFactor * (MAX_DIFFICULTY_BITS - MIN_DIFFICULTY_BITS);
|
|
886
611
|
|
|
887
|
-
//
|
|
612
|
+
// The target is max / 2^bits
|
|
888
613
|
return MAX_DIFFICULTY_TARGET >> BigInt(Math.floor(totalDifficultyBits));
|
|
889
614
|
}
|
|
890
615
|
|
|
891
616
|
/**
|
|
892
|
-
*
|
|
617
|
+
* Generates a CPU challenge based on a target.
|
|
893
618
|
*/
|
|
894
619
|
export function generateCpuTargetChallenge(
|
|
895
620
|
clientIp,
|
|
@@ -901,7 +626,7 @@ export function generateCpuTargetChallenge(
|
|
|
901
626
|
return {
|
|
902
627
|
type: "cpu_target",
|
|
903
628
|
nonce: nonce,
|
|
904
|
-
target: target.toString(16), //
|
|
629
|
+
target: target.toString(16), // Send the target in hexadecimal
|
|
905
630
|
path: originalUrl,
|
|
906
631
|
};
|
|
907
632
|
}
|
|
@@ -942,7 +667,67 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
|
|
|
942
667
|
}
|
|
943
668
|
|
|
944
669
|
/**
|
|
945
|
-
*
|
|
670
|
+
* Generates the HTML content for a combined CPU + Memory PoW challenge.
|
|
671
|
+
* @param {object} cpuChallengeDetails - Details from generateCpuTargetChallenge.
|
|
672
|
+
* @param {number} memoryDifficulty - Memory allocation in MB.
|
|
673
|
+
* @param {string} clientIp - The client's IP address.
|
|
674
|
+
* @returns {string} HTML content.
|
|
675
|
+
*/
|
|
676
|
+
function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp) {
|
|
677
|
+
const { nonce, target, path } = cpuChallengeDetails;
|
|
678
|
+
return `
|
|
679
|
+
<html><head><title>Advanced Security Check</title></head>
|
|
680
|
+
<body style="font-family:sans-serif; text-align:center; padding-top:50px;">
|
|
681
|
+
<h1>Enhanced Verification... (Level 2)</h1>
|
|
682
|
+
<p>Your activity requires an additional security check. This may take a few moments.</p>
|
|
683
|
+
<div id="loader" style="margin:20px;">⚙️ Initializing combined verification...</div>
|
|
684
|
+
<script>
|
|
685
|
+
async function solve() {
|
|
686
|
+
const nonce = "${nonce}";
|
|
687
|
+
const path = "${path}";
|
|
688
|
+
|
|
689
|
+
// --- CPU Challenge ---
|
|
690
|
+
document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
|
|
691
|
+
const cpuTarget = BigInt("0x${target}");
|
|
692
|
+
let cpuSolution = 0;
|
|
693
|
+
while (true) {
|
|
694
|
+
const msg = "${clientIp}:${nonce}:" + cpuSolution;
|
|
695
|
+
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
|
|
696
|
+
const hashHex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
697
|
+
if (BigInt('0x' + hashHex) < cpuTarget) break;
|
|
698
|
+
cpuSolution++;
|
|
699
|
+
if (cpuSolution % 100000 === 0) await new Promise(r => setTimeout(r, 0));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// --- Memory Challenge ---
|
|
703
|
+
document.getElementById('loader').innerText = '⚙️ Performing memory allocation and calculation... (${memoryDifficulty} MB)';
|
|
704
|
+
await new Promise(r => setTimeout(r, 10)); // Yield to update UI
|
|
705
|
+
|
|
706
|
+
let memSolution = 0;
|
|
707
|
+
try {
|
|
708
|
+
const size = ${memoryDifficulty} * 1024 * 1024;
|
|
709
|
+
const buffer = new Uint32Array(size / 4);
|
|
710
|
+
let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
|
|
711
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
712
|
+
buffer[i] = (h = Math.imul(h ^ i, 1597334677));
|
|
713
|
+
}
|
|
714
|
+
for(let i = 0; i < (size / 16); i++) {
|
|
715
|
+
const addr = buffer[i % buffer.length] % buffer.length;
|
|
716
|
+
memSolution ^= buffer[addr];
|
|
717
|
+
}
|
|
718
|
+
} catch(e) {
|
|
719
|
+
document.getElementById('loader').innerText = "Error: Insufficient memory. Please refresh.";
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
window.location.href = path + "?pow_type=cpu_mem&pow_nonce=" + nonce + "&pow_solution_cpu=" + cpuSolution + "&pow_solution_mem=" + memSolution;
|
|
723
|
+
}
|
|
724
|
+
solve();
|
|
725
|
+
</script>
|
|
726
|
+
</body></html>`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Verifies a PoW solution based on a target and generates a ticket.
|
|
946
731
|
*/
|
|
947
732
|
export function verifyCpuTargetPoWAndGenerateTicket(
|
|
948
733
|
clientIp,
|
|
@@ -958,8 +743,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
|
|
|
958
743
|
const hashAsInt = BigInt("0x" + hash);
|
|
959
744
|
|
|
960
745
|
if (hashAsInt < target) {
|
|
961
|
-
//
|
|
962
|
-
//
|
|
746
|
+
// The comparison is direct with native BigInts
|
|
747
|
+
// The proof is valid, generate the ticket
|
|
963
748
|
const expiry = Date.now() + 3600000; // 1 heure
|
|
964
749
|
const signature = crypto
|
|
965
750
|
.createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
|
|
@@ -976,51 +761,56 @@ const staticExtensions =
|
|
|
976
761
|
const isStaticResource = (req) => staticExtensions.test(req.path);
|
|
977
762
|
|
|
978
763
|
// --- Middleware Proof-of-Work (Le péage) ---
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
if (isStaticResource(req)) {
|
|
982
|
-
return next();
|
|
983
|
-
}
|
|
984
|
-
|
|
764
|
+
class FingerprintEngine {
|
|
765
|
+
constructor(securityConfig) {
|
|
985
766
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
986
|
-
|
|
767
|
+
this.securityConfig = securityConfig;
|
|
768
|
+
this.isProduction = isProduction;
|
|
769
|
+
}
|
|
987
770
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
const
|
|
771
|
+
async processRequest(requestContext) {
|
|
772
|
+
const { clientIp, path, cookies, query, isStatic } = requestContext;
|
|
773
|
+
const { weights, thresholds, logger } = this.securityConfig;
|
|
774
|
+
|
|
775
|
+
if (isStatic) {
|
|
776
|
+
return { action: 'next', score: 0, vector: {} };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// The engine now works with the context directly, no more rawReq dependency here.
|
|
780
|
+
const suspicionVector = await __internal.getSuspicionVector(requestContext);
|
|
991
781
|
|
|
992
782
|
const finalScore =
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
783
|
+
suspicionVector.historyScore * (weights.historyScore || 0) +
|
|
784
|
+
suspicionVector.rotationScore * (weights.rotationScore || 0) +
|
|
785
|
+
suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) +
|
|
786
|
+
suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0);
|
|
997
787
|
|
|
998
788
|
const isSuspiciousHigh = finalScore >= thresholds.high;
|
|
999
789
|
const isSuspiciousMedium = finalScore >= thresholds.medium;
|
|
1000
790
|
const isSuspicious = finalScore >= thresholds.low;
|
|
1001
791
|
|
|
1002
|
-
//
|
|
792
|
+
// Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
|
|
1003
793
|
const suspicionFactor = isSuspicious
|
|
1004
794
|
? Math.min(
|
|
1005
795
|
1,
|
|
1006
796
|
(finalScore - thresholds.low) / (thresholds.high - thresholds.low),
|
|
1007
797
|
)
|
|
1008
798
|
: 0;
|
|
1009
|
-
const powCookie =
|
|
1010
|
-
const { pow_type, pow_nonce, pow_solution,
|
|
799
|
+
const powCookie = cookies?.pow_clearance;
|
|
800
|
+
const { pow_type, pow_nonce, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
|
|
1011
801
|
|
|
1012
802
|
if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
|
|
1013
|
-
// ---
|
|
1014
|
-
if (pow_nonce && pow_solution) {
|
|
803
|
+
// --- CHALLENGE SOLUTION HANDLING ---
|
|
804
|
+
if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
|
|
1015
805
|
let isValid = false,
|
|
1016
806
|
ticket = null;
|
|
1017
807
|
if (pow_type === "cpu_target") {
|
|
1018
|
-
//
|
|
808
|
+
// Verify the new type
|
|
1019
809
|
ticket = verifyCpuTargetPoWAndGenerateTicket(
|
|
1020
810
|
clientIp,
|
|
1021
811
|
pow_nonce,
|
|
1022
812
|
pow_solution,
|
|
1023
|
-
suspicionFactor, //
|
|
813
|
+
suspicionFactor, // Pass the analog factor directly
|
|
1024
814
|
);
|
|
1025
815
|
isValid = ticket !== null;
|
|
1026
816
|
} else if (pow_type === "mem") {
|
|
@@ -1029,14 +819,27 @@ export const powMiddleware = (securityConfig) => async (req, res, next) => {
|
|
|
1029
819
|
const difficulty =
|
|
1030
820
|
minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
|
|
1031
821
|
isValid = verifyMemoryPoW(pow_nonce, pow_solution, difficulty);
|
|
822
|
+
} else if (pow_type === "cpu_mem") {
|
|
823
|
+
// Verify combined challenge
|
|
824
|
+
const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
|
|
825
|
+
clientIp, pow_nonce, pow_solution_cpu, suspicionFactor
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
const minDifficulty = 16; // 16Mo
|
|
829
|
+
const maxDifficulty = 48; // 48Mo
|
|
830
|
+
const memDifficulty = minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
|
|
831
|
+
const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty);
|
|
832
|
+
|
|
833
|
+
isValid = cpuTicket !== null && isMemValid;
|
|
834
|
+
if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
|
|
1032
835
|
} else if (pow_type === "tsp") {
|
|
1033
|
-
//
|
|
836
|
+
// Logic for TSP remains the same
|
|
1034
837
|
// ...
|
|
1035
838
|
}
|
|
1036
839
|
|
|
1037
840
|
if (isValid) {
|
|
1038
841
|
if (!ticket) {
|
|
1039
|
-
//
|
|
842
|
+
// If the ticket has not already been generated (CPU case)
|
|
1040
843
|
const expiry = Date.now() + 3600000; // 1 heure
|
|
1041
844
|
const signature = crypto
|
|
1042
845
|
.createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
|
|
@@ -1045,50 +848,181 @@ export const powMiddleware = (securityConfig) => async (req, res, next) => {
|
|
|
1045
848
|
ticket = `${expiry}:${signature}`;
|
|
1046
849
|
}
|
|
1047
850
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
851
|
+
if (logger) {
|
|
852
|
+
logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: finalScore, challengeType: pow_type, timestamp: Date.now() });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
action: 'redirect',
|
|
857
|
+
path: path,
|
|
858
|
+
score: finalScore,
|
|
859
|
+
vector: suspicionVector,
|
|
860
|
+
cookie: {
|
|
861
|
+
name: 'pow_clearance',
|
|
862
|
+
value: ticket,
|
|
863
|
+
options: {
|
|
864
|
+
httpOnly: true,
|
|
865
|
+
secure: this.isProduction,
|
|
866
|
+
maxAge: 3600000,
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
};
|
|
1054
870
|
}
|
|
1055
871
|
}
|
|
1056
872
|
|
|
1057
|
-
// ---
|
|
873
|
+
// --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
|
|
1058
874
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
1059
875
|
|
|
1060
|
-
|
|
876
|
+
if (logger) {
|
|
877
|
+
logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// LEVEL 3: CAPTCHA (the highest)
|
|
1061
881
|
if (isSuspiciousHigh) {
|
|
1062
|
-
// ...
|
|
882
|
+
// ... logic for TSP/Captcha challenge
|
|
1063
883
|
}
|
|
1064
884
|
|
|
1065
|
-
//
|
|
885
|
+
// LEVEL 2: Memory-Intensive PoW
|
|
1066
886
|
if (isSuspiciousMedium) {
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
887
|
+
// Utilisons notre nouveau challenge combiné !
|
|
888
|
+
const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
|
|
889
|
+
|
|
890
|
+
const minMemDifficulty = 16; // 16Mo
|
|
891
|
+
const maxMemDifficulty = 48; // 48Mo
|
|
892
|
+
const memDifficulty = minMemDifficulty + suspicionFactor * (maxMemDifficulty - minMemDifficulty);
|
|
893
|
+
|
|
894
|
+
const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
|
|
895
|
+
return {
|
|
896
|
+
action: 'challenge', score: finalScore, vector: suspicionVector,
|
|
897
|
+
status: 429, body: page
|
|
898
|
+
};
|
|
1076
899
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
const
|
|
1088
|
-
|
|
900
|
+
|
|
901
|
+
// NOUVELLE LOGIQUE UNIFIÉE POUR TOUS LES NIVEAUX DE SUSPICION (low et medium)
|
|
902
|
+
if (isSuspicious) { // Couvre à la fois low et medium
|
|
903
|
+
const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
|
|
904
|
+
|
|
905
|
+
// La difficulté mémoire démarre à 0 et augmente seulement après un certain seuil de suspicion.
|
|
906
|
+
// Par exemple, elle ne commence à augmenter qu'à partir de 25% du chemin entre 'low' et 'high'.
|
|
907
|
+
const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
|
|
908
|
+
|
|
909
|
+
const minMemDifficulty = 0; // Peut être 0 Mo !
|
|
910
|
+
const maxMemDifficulty = 48; // 48Mo pour les plus suspects
|
|
911
|
+
const memDifficulty = minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty);
|
|
912
|
+
|
|
913
|
+
// On utilise toujours la page combinée, même si la difficulté mémoire est 0 (le calcul sera quasi instantané).
|
|
914
|
+
const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
|
|
915
|
+
return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
|
|
1089
916
|
}
|
|
1090
917
|
}
|
|
1091
|
-
|
|
918
|
+
|
|
919
|
+
// Basic log for each non-static request that passed without a challenge
|
|
920
|
+
if (logger) {
|
|
921
|
+
logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return { action: 'next', score: finalScore, vector: suspicionVector };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Identifies a request in a granular way for non-Express environments.
|
|
929
|
+
* @param {object} requestContext - The request context object.
|
|
930
|
+
* @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
|
|
931
|
+
*/
|
|
932
|
+
async identifyRequest(requestContext) {
|
|
933
|
+
const { clientIp, cookies, rawReq, rawRes } = requestContext;
|
|
934
|
+
|
|
935
|
+
// --- Update IP reputation ---
|
|
936
|
+
const ipProfile = (await store.get(`ip:${clientIp}`)) || {
|
|
937
|
+
type: "residential",
|
|
938
|
+
deviceIds: new Set(),
|
|
939
|
+
statelessCount: 0,
|
|
940
|
+
lastSeen: 0,
|
|
941
|
+
};
|
|
942
|
+
ipProfile.lastSeen = Date.now();
|
|
943
|
+
if (cookies?.device_id) {
|
|
944
|
+
ipProfile.deviceIds.add(cookies.device_id);
|
|
945
|
+
} else {
|
|
946
|
+
ipProfile.statelessCount++;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
|
|
950
|
+
ipProfile.type = "shared";
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
|
|
954
|
+
if (ipProfile.statelessCount > statelessLimit) {
|
|
955
|
+
return `suspicious_high:${clientIp}`;
|
|
956
|
+
}
|
|
957
|
+
await store.set(`ip:${clientIp}`, ipProfile);
|
|
958
|
+
|
|
959
|
+
const vector = await __internal.getSuspicionVector(requestContext);
|
|
960
|
+
const score =
|
|
961
|
+
vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
|
|
962
|
+
vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
|
|
963
|
+
vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
|
|
964
|
+
vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8);
|
|
965
|
+
|
|
966
|
+
if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
|
|
967
|
+
if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
|
|
968
|
+
if (score >= this.securityConfig.thresholds.medium) return `suspicious_medium:${clientIp}`;
|
|
969
|
+
|
|
970
|
+
// If a new device_id was created, it's in the context.
|
|
971
|
+
const newDeviceId = requestContext._newCookies?.find(c => c.name === 'device_id')?.value;
|
|
972
|
+
const finalDeviceId = cookies?.device_id || newDeviceId || clientIp;
|
|
973
|
+
|
|
974
|
+
return `device:${finalDeviceId}`;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// --- Proof-of-Work Middleware (The Tollbooth) ---
|
|
979
|
+
export const powMiddleware = (securityConfig) => {
|
|
980
|
+
const engine = new FingerprintEngine(securityConfig);
|
|
981
|
+
|
|
982
|
+
return async (req, res, next) => {
|
|
983
|
+
const requestContext = {
|
|
984
|
+
clientIp: req.ip || req.socket?.remoteAddress || "unknown",
|
|
985
|
+
path: req.path,
|
|
986
|
+
cookies: req.cookies,
|
|
987
|
+
query: req.query,
|
|
988
|
+
headers: req.headers,
|
|
989
|
+
isStatic: isStaticResource(req),
|
|
990
|
+
// Add the newly required properties for full decoupling
|
|
991
|
+
rawHeaders: req.rawHeaders,
|
|
992
|
+
httpVersion: req.httpVersion,
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const decision = await engine.processRequest(requestContext);
|
|
996
|
+
|
|
997
|
+
// Attach the fingerprinting result to the request object for downstream middlewares.
|
|
998
|
+
req.fingerprint = {
|
|
999
|
+
score: decision.score,
|
|
1000
|
+
vector: decision.vector,
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
// After getSuspicionVector runs, it might have attached cookies to be set.
|
|
1004
|
+
if (requestContext._newCookies) {
|
|
1005
|
+
requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
switch (decision.action) {
|
|
1009
|
+
case 'block':
|
|
1010
|
+
return res.status(decision.status).send(decision.body);
|
|
1011
|
+
|
|
1012
|
+
case 'challenge':
|
|
1013
|
+
return res.status(decision.status).send(decision.body);
|
|
1014
|
+
|
|
1015
|
+
case 'redirect':
|
|
1016
|
+
if (decision.cookie) {
|
|
1017
|
+
res.cookie(decision.cookie.name, decision.cookie.value, decision.cookie.options);
|
|
1018
|
+
}
|
|
1019
|
+
return res.redirect(decision.path);
|
|
1020
|
+
|
|
1021
|
+
case 'next':
|
|
1022
|
+
default:
|
|
1023
|
+
return next();
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1092
1026
|
};
|
|
1093
1027
|
|
|
1094
1028
|
/**
|
|
@@ -1097,6 +1031,133 @@ export const powMiddleware = (securityConfig) => async (req, res, next) => {
|
|
|
1097
1031
|
* This is a common pattern to allow mocking of ES module functions.
|
|
1098
1032
|
*/
|
|
1099
1033
|
export const __internal = {
|
|
1034
|
+
getDeviceHash,
|
|
1100
1035
|
getSuspicionVector,
|
|
1036
|
+
cyrb53, // Export for testing
|
|
1037
|
+
FingerprintBuilder, // Export for testing
|
|
1101
1038
|
calculateTarget,
|
|
1039
|
+
FingerprintEngine, // Expose for advanced testing
|
|
1102
1040
|
};
|
|
1041
|
+
|
|
1042
|
+
// --- THRESHOLD AUTO-TUNING SECTION ---
|
|
1043
|
+
|
|
1044
|
+
let autoTuningJobId = null;
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Executes a threshold optimization pass using collected traffic data.
|
|
1048
|
+
* @private
|
|
1049
|
+
* @param {object} securityConfig - The security configuration object to update.
|
|
1050
|
+
* @param {Array<object>} trafficData - The array containing traffic logs.
|
|
1051
|
+
* @param {number} minDataPoints - The minimum number of data points required to start optimization.
|
|
1052
|
+
*/
|
|
1053
|
+
function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
|
|
1054
|
+
if (trafficData.length < minDataPoints) {
|
|
1055
|
+
console.log(`[AutoTuning] Reporté : ${trafficData.length}/${minDataPoints} points de données.`);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
console.log(`[AutoTuning] Démarrage du cycle d'optimisation avec ${trafficData.length} points de données.`);
|
|
1059
|
+
|
|
1060
|
+
// Identify "bots" (those who received a challenge but never solved it)
|
|
1061
|
+
// and "humans" (those who passed the challenge or never received one).
|
|
1062
|
+
const solvedDevices = new Set(trafficData.filter(e => e.type === 'challenge_solved').map(e => e.deviceId));
|
|
1063
|
+
const historicalRequests = trafficData.map(log => {
|
|
1064
|
+
let isBot = false;
|
|
1065
|
+
if (log.type === 'challenge_issued' && !solvedDevices.has(log.deviceId)) {
|
|
1066
|
+
isBot = true; // Assumption: a challenge issued and not solved is a bot.
|
|
1067
|
+
}
|
|
1068
|
+
return { score: log.score, isBot };
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// The "fitness" function evaluates the quality of a set of thresholds.
|
|
1072
|
+
// A lower score is better.
|
|
1073
|
+
const fitnessFunction = (solution) => {
|
|
1074
|
+
const [low, medium, high] = solution;
|
|
1075
|
+
// Constraints: thresholds must be ordered and within a reasonable range.
|
|
1076
|
+
if (low >= medium || medium >= high || low <= 10 || high >= 90) return Infinity;
|
|
1077
|
+
|
|
1078
|
+
let falsePositives = 0; // Humans challenged unnecessarily.
|
|
1079
|
+
let falseNegatives = 0; // Undetected bots.
|
|
1080
|
+
|
|
1081
|
+
for (const req of historicalRequests) {
|
|
1082
|
+
if (req.isBot) {
|
|
1083
|
+
if (req.score < low) falseNegatives++;
|
|
1084
|
+
} else { // Human
|
|
1085
|
+
if (req.score >= low) falsePositives++;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// Penalize passing bots 2x more than inconvenienced humans.
|
|
1089
|
+
return (falsePositives * 1.0) + (falseNegatives * 2.0);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// Functions for the genetic algorithm.
|
|
1093
|
+
const createIndividual = () => [10 + Math.random() * 20, 30 + Math.random() * 30, 60 + Math.random() * 30];
|
|
1094
|
+
const crossover = (p1, p2) => [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
|
|
1095
|
+
const mutate = (s) => {
|
|
1096
|
+
const n = [...s];
|
|
1097
|
+
const i = Math.floor(Math.random() * 3);
|
|
1098
|
+
n[i] += (Math.random() - 0.5) * 5;
|
|
1099
|
+
return n;
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
// Start optimization.
|
|
1103
|
+
const result = Optimization.geneticAlgorithm(createIndividual, fitnessFunction, crossover, mutate, {
|
|
1104
|
+
generations: 50,
|
|
1105
|
+
populationSize: 40
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
const [newLow, newMedium, newHigh] = result.solution;
|
|
1109
|
+
|
|
1110
|
+
// Update the configuration live.
|
|
1111
|
+
securityConfig.thresholds = {
|
|
1112
|
+
low: Math.round(newLow),
|
|
1113
|
+
medium: Math.round(newMedium),
|
|
1114
|
+
high: Math.round(newHigh)
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
console.log("[AutoTuning] Nouveaux seuils optimisés appliqués :", securityConfig.thresholds);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Starts the background process for auto-tuning security thresholds.
|
|
1122
|
+
* @export
|
|
1123
|
+
* @param {object} options - Configuration options for auto-tuning.
|
|
1124
|
+
* @param {object} options.securityConfig - The live security configuration object that will be mutated.
|
|
1125
|
+
* @param {Array<object>} options.trafficData - The array where the logger pushes traffic data.
|
|
1126
|
+
* @param {number} [options.interval=1800000] - The interval in milliseconds between each optimization cycle (default: 30 minutes).
|
|
1127
|
+
* @param {number} [options.minDataPoints=200] - The minimum number of requests to analyze before starting a cycle (default: 200).
|
|
1128
|
+
*/
|
|
1129
|
+
export function startThresholdAutoTuning(options) {
|
|
1130
|
+
if (autoTuningJobId) {
|
|
1131
|
+
console.warn("[AutoTuning] Le job est déjà en cours d'exécution.");
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const {
|
|
1136
|
+
securityConfig,
|
|
1137
|
+
trafficData,
|
|
1138
|
+
interval = 1800000,
|
|
1139
|
+
minDataPoints = 200
|
|
1140
|
+
} = options;
|
|
1141
|
+
|
|
1142
|
+
if (!securityConfig || !trafficData) {
|
|
1143
|
+
throw new Error("[AutoTuning] `securityConfig` et `trafficData` sont requis.");
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
console.log(`[AutoTuning] Job d'optimisation des seuils démarré. Prochain cycle dans ${interval / 60000} minutes.`);
|
|
1147
|
+
|
|
1148
|
+
autoTuningJobId = setInterval(() => {
|
|
1149
|
+
runThresholdOptimization(securityConfig, trafficData, minDataPoints);
|
|
1150
|
+
}, interval);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Stops the threshold auto-tuning process.
|
|
1155
|
+
* @export
|
|
1156
|
+
*/
|
|
1157
|
+
export function stopThresholdAutoTuning() {
|
|
1158
|
+
if (autoTuningJobId) {
|
|
1159
|
+
clearInterval(autoTuningJobId);
|
|
1160
|
+
autoTuningJobId = null;
|
|
1161
|
+
console.log("[AutoTuning] Job d'optimisation des seuils arrêté.");
|
|
1162
|
+
}
|
|
1163
|
+
}
|