@anonympins/fingerprint 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fingerprint.js CHANGED
@@ -383,6 +383,8 @@ export const verifyPoWAndGenerateTicket = (
383
383
  return `${expiry}:${signature}`;
384
384
  };
385
385
 
386
+
387
+
386
388
  /**
387
389
  * Verifies a memory PoW solution.
388
390
  * The server performs the same calculation to validate.
@@ -559,6 +561,28 @@ function getHoneypotScore(context, honeypotConfig = {}) {
559
561
  return { honeypotScore: 0 };
560
562
  }
561
563
 
564
+ /**
565
+ * Calcule un score basé sur les métriques comportementales envoyées par le client.
566
+ * @param {object} context - Le contexte de la requête, contenant les en-têtes.
567
+ * @returns {{behaviorScore: number}}
568
+ */
569
+ function getBehaviorScore(context) {
570
+ const behaviorHeader = context.headers['x-behavior-metrics'];
571
+ if (!behaviorHeader) {
572
+ return { behaviorScore: 0 }; // Pas de données, pas de pénalité.
573
+ }
574
+
575
+ try {
576
+ const metrics = JSON.parse(behaviorHeader);
577
+ let score = 0;
578
+ if (metrics.honeypotInteraction) score = 100; // Interaction avec un honeypot client = bot.
579
+ if (metrics.mouseEntropy === 0 && metrics.keystrokeLatency === 0) score += 40; // Aucune interaction = suspect.
580
+ return { behaviorScore: Math.min(100, score) };
581
+ } catch (e) {
582
+ return { behaviorScore: 10 }; // En-tête malformé = légèrement suspect.
583
+ }
584
+ }
585
+
562
586
  /**
563
587
  * Analyzes server-side request patterns for a given device to detect bot-like behavior.
564
588
  * This is a stateful check that looks for repetitive or unnaturally fast requests.
@@ -752,7 +776,7 @@ export const configureStore = (externalStore) => {
752
776
  * @param {object} context - The request context.
753
777
  * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
754
778
  */
755
- async function resolveRequestIdentity(context) {
779
+ async function resolveRequestIdentity(context, securityConfig = {}) {
756
780
  const existingDeviceId = context.cookies?.device_id;
757
781
  const currentDeviceHash = getDeviceHash(context);
758
782
  let deviceId = existingDeviceId;
@@ -781,7 +805,9 @@ async function resolveRequestIdentity(context) {
781
805
  name: "device_id",
782
806
  value: deviceId,
783
807
  options: {
784
- httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: 31536000000, // 1 year
808
+ httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict",
809
+ // Le maxAge est maintenant configurable. Par défaut, c'est un cookie de session.
810
+ ...(securityConfig.deviceIdCookieMaxAge && { maxAge: securityConfig.deviceIdCookieMaxAge }),
785
811
  }
786
812
  };
787
813
 
@@ -866,7 +892,7 @@ async function getBehavioralIndicators(context, deviceData) {
866
892
  * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
867
893
  */
868
894
  export const getSuspicionVector = async (context, securityConfig) => {
869
- const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
895
+ const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
870
896
 
871
897
  const clientIp = context.clientIp;
872
898
 
@@ -888,8 +914,16 @@ export const getSuspicionVector = async (context, securityConfig) => {
888
914
  const behavioral = await getBehavioralIndicators(context, deviceData);
889
915
  const { headerAnomalyScore } = getHeaderAnomalies(context);
890
916
  // Calculate the inconsistency score here, separately.
891
- const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
917
+ let inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200)); // Amplified score
918
+
919
+ // NOUVEAU: Si l'incohérence est très forte (cookie probablement volé), on applique une pénalité maximale.
920
+ if (consistencyScore < 0.7) { // Seuil de rupture
921
+ inconsistencyScore = 100;
922
+ }
923
+
924
+ const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
892
925
 
926
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
893
927
 
894
928
  // Save the updated device state to the store
895
929
  // Note: deviceData.ips is a Set, which may not serialize correctly in all stores (e.g., JSON). A Redis store should handle this via custom serialization or by converting to an array.
@@ -900,7 +934,8 @@ export const getSuspicionVector = async (context, securityConfig) => {
900
934
  if (Array.isArray(deviceData.ips)) {
901
935
  deviceData.ips = new Set(deviceData.ips);
902
936
  }
903
- return { ...behavioral, headerAnomalyScore, inconsistencyScore };
937
+ // Correction : Ajouter behaviorScore à l'objet retourné
938
+ return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, requestPatternScore };
904
939
  };
905
940
 
906
941
  // A residential user can change networks (home, 4G, public wifi).
@@ -1100,6 +1135,7 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
1100
1135
  */
1101
1136
  export function verifyCpuTargetPoWAndGenerateTicket(
1102
1137
  clientIp, // This parameter is crucial and must be the actual client IP
1138
+ ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
1103
1139
  nonce,
1104
1140
  solution,
1105
1141
  suspicionFactor,
@@ -1118,8 +1154,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
1118
1154
  if (hashAsInt < target) {
1119
1155
  // The comparison is direct with native BigInts
1120
1156
  // The proof is valid, generate the ticket
1121
- const expiry = Date.now() + 3600000; // 1 heure
1122
- const signature = crypto
1157
+ const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
1158
+ const signature = crypto
1123
1159
  .createHmac("sha256", getPowSecret())
1124
1160
  .update(`${clientIp}:${expiry}`)
1125
1161
  .digest("hex");
@@ -1135,6 +1171,72 @@ const staticExtensions = new RegExp(
1135
1171
  );
1136
1172
  const isStaticResource = (path) => staticExtensions.test(path);
1137
1173
 
1174
+
1175
+ /**
1176
+ * Détermine le TTL optimal pour un ticket en utilisant un algorithme génétique multi-objectifs.
1177
+ * @param {number} suspicionScore - Le score de suspicion de la requête.
1178
+ * @returns {number} Le TTL optimal calculé en millisecondes.
1179
+ */
1180
+ function determineOptimalTicketTtl(suspicionScore) {
1181
+ // Définir les bornes pour la durée de vie du ticket (5 minutes à 24 heures)
1182
+ const MIN_TTL = 300000;
1183
+ const MAX_TTL = 86400000;
1184
+
1185
+ const solverFunction = () => {
1186
+ const fitnessFunction = Optimization.Operators.createOptimalTtlEvaluator({ suspicionScore });
1187
+
1188
+ // Un "individu" est simplement une valeur de TTL en millisecondes.
1189
+ const createIndividual = () => MIN_TTL + Math.random() * (MAX_TTL - MIN_TTL);
1190
+ const crossover = (ttl1, ttl2) => (ttl1 + ttl2) / 2;
1191
+ const mutate = (ttl) => {
1192
+ const newTtl = ttl + (Math.random() - 0.5) * (MAX_TTL - MIN_TTL) * 0.1; // Mutation de +/- 10% max
1193
+ return Math.max(MIN_TTL, Math.min(MAX_TTL, newTtl));
1194
+ };
1195
+
1196
+ const paretoFront = Optimization.geneticAlgorithmMultiObjective(
1197
+ createIndividual,
1198
+ fitnessFunction,
1199
+ crossover,
1200
+ mutate,
1201
+ {
1202
+ generations: 40,
1203
+ populationSize: 30,
1204
+ }
1205
+ );
1206
+
1207
+ // Pour runMultiple, on doit retourner un objet avec une propriété "fitness" ou "energy".
1208
+ // Pour un front de Pareto, il n'y a pas de score unique. On choisit la meilleure solution
1209
+ // en fonction du score de suspicion et on lui assigne un score de 0 pour que runMultiple la sélectionne.
1210
+ if (!paretoFront || paretoFront.length === 0) {
1211
+ return { solution: null, fitness: Infinity };
1212
+ }
1213
+
1214
+ // Stratégie de sélection :
1215
+ // Pour un score faible (< 50), on privilégie la solution avec le plus grand TTL (minimise la friction).
1216
+ // Pour un score élevé (>= 50), on privilégie la solution avec le plus petit TTL (minimise le risque).
1217
+ let bestSolutionInFront;
1218
+ if (suspicionScore < 50) {
1219
+ bestSolutionInFront = paretoFront.reduce((max, p) => Math.max(max, p.solution), 0);
1220
+ } else {
1221
+ bestSolutionInFront = paretoFront.reduce((min, p) => Math.min(min, p.solution), Infinity);
1222
+ }
1223
+ return { solution: bestSolutionInFront, fitness: 0 }; // fitness=0 car on a déjà la meilleure solution du cycle.
1224
+ };
1225
+
1226
+ // On exécute le solveur 20 fois pour trouver une solution plus stable et robuste.
1227
+ const { bestResult } = Optimization.runMultiple(solverFunction, 20);
1228
+
1229
+ if (!bestResult || !bestResult.solution || bestResult.solution === Infinity) {
1230
+ // Fallback : si l'algo ne retourne rien, on applique une règle simple et sûre.
1231
+ return Math.max(MIN_TTL, MAX_TTL - (suspicionScore / 100) * MAX_TTL);
1232
+ }
1233
+
1234
+ // runMultiple choisit le meilleur résultat sur la base du score (ici, 0).
1235
+ // La "meilleure" solution dépendra du cycle qui a trouvé le meilleur compromis.
1236
+ return bestResult.solution;
1237
+ }
1238
+
1239
+
1138
1240
  // --- Middleware Proof-of-Work (Le péage) ---
1139
1241
  export class FingerprintEngine {
1140
1242
  constructor(securityConfig) {
@@ -1143,7 +1245,21 @@ export class FingerprintEngine {
1143
1245
  this.isProduction = isProduction;
1144
1246
  this._allowlist = this._buildAllowlist();
1145
1247
  }
1146
-
1248
+ calculateFinalScore = function(suspicionVector) {
1249
+ const { weights } = this.securityConfig;
1250
+ if (!weights) return 0;
1251
+
1252
+ const score =
1253
+ (suspicionVector.historyScore || 0) * (weights.historyScore || 0) +
1254
+ (suspicionVector.rotationScore || 0) * (weights.rotationScore || 0) +
1255
+ (suspicionVector.headerAnomalyScore || 0) * (weights.headerAnomalyScore || 0) +
1256
+ (suspicionVector.requestPatternScore || 0) * (weights.requestPatternScore || 0) +
1257
+ (suspicionVector.inconsistencyScore || 0) * (weights.inconsistencyScore || 0) +
1258
+ (suspicionVector.honeypotScore || 0) * (weights.honeypotScore || 0) +
1259
+ (suspicionVector.behaviorScore || 0) * (weights.behaviorScore || 0);
1260
+
1261
+ return Math.min(100, score);
1262
+ }
1147
1263
  /**
1148
1264
  * Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
1149
1265
  * This is the fastest check and should be performed first.
@@ -1242,6 +1358,7 @@ export class FingerprintEngine {
1242
1358
  }
1243
1359
 
1244
1360
  async processRequest(requestContext) {
1361
+
1245
1362
  const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
1246
1363
  const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
1247
1364
  if (isStatic) {
@@ -1256,13 +1373,13 @@ export class FingerprintEngine {
1256
1373
  const { pow_nonce } = query;
1257
1374
 
1258
1375
  // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1259
- // A legitimate user only hits these endpoints via the challenge page itself, which is only served to suspicious users.
1260
- // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot.
1261
- // We check this early, before the main suspicion calculation.
1376
+ // A legitimate user only hits these endpoints via the challenge page itself.
1377
+ // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot probe.
1262
1378
  if (pow_nonce) {
1263
1379
  const powCookie = cookies?.pow_clearance;
1264
1380
  if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
1265
1381
  // This is a potential probe. We'll let the main logic confirm if it's not a legitimate challenge response.
1382
+ // The final decision is made later, after calculating the score.
1266
1383
  }
1267
1384
  }
1268
1385
 
@@ -1271,8 +1388,72 @@ export class FingerprintEngine {
1271
1388
  return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
1272
1389
  }
1273
1390
 
1274
- // Check for persisted "condemned" status early.
1275
- const { deviceData } = await resolveRequestIdentity(requestContext);
1391
+ // --- NOUVELLE LOGIQUE DE PRIORITÉ ---
1392
+ // Si une solution de challenge est soumise, on la traite en priorité absolue,
1393
+ // avant même de recalculer le score de suspicion.
1394
+ const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1395
+ if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1396
+ // On doit calculer le score de suspicion *avant* de valider le ticket,
1397
+ // car le TTL optimal en dépend.
1398
+ const preliminaryVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1399
+ const preliminaryScore = this.calculateFinalScore(preliminaryVector);
1400
+ let isValid = false;
1401
+ const challengeContext = await store.get(`secret:${pow_nonce}`);
1402
+ let ticket = null;
1403
+
1404
+ if (challengeContext) {
1405
+ const optimalTtl = determineOptimalTicketTtl(preliminaryScore);
1406
+ if (pow_type === "cpu_target") {
1407
+ // On passe la durée de vie du ticket configurée
1408
+ ticket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution, null, challengeContext.clientSecret, challengeContext.cpuTarget);
1409
+ isValid = ticket !== null;
1410
+ } else if (pow_type === "cpu_mem") {
1411
+ const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(clientIp, optimalTtl, pow_nonce, pow_solution_cpu, null, challengeContext.clientSecret, challengeContext.cpuTarget);
1412
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, challengeContext.memDifficulty, challengeContext.clientSecret);
1413
+ isValid = cpuTicket !== null && isMemValid;
1414
+ if (isValid) ticket = cpuTicket; // Le ticket est le même, on le réutilise
1415
+ }
1416
+ }
1417
+
1418
+ if (isValid) {
1419
+ // La solution est valide. On supprime le secret et on redirige.
1420
+ await store.delete(`secret:${pow_nonce}`);
1421
+
1422
+ if (logger) {
1423
+ logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: preliminaryScore, challengeType: pow_type, timestamp: Date.now() });
1424
+ }
1425
+
1426
+ return {
1427
+ action: 'redirect',
1428
+ path: path,
1429
+ score: 0, // Le score n'est pas pertinent ici, on a passé le test.
1430
+ vector: { challenge_solved: 100 },
1431
+ cookie: {
1432
+ name: 'pow_clearance',
1433
+ value: ticket,
1434
+ options: {
1435
+ httpOnly: true,
1436
+ secure: this.isProduction, // Le maxAge est déjà inclus dans le ticket, mais on le met aussi sur le cookie
1437
+ maxAge: this.securityConfig.ticketMaxAge || 3600000,
1438
+ }
1439
+ }
1440
+ };
1441
+ }
1442
+ // Si la solution est INVALIDE, on ne fait rien ici. La requête continuera son cours normal,
1443
+ // sera recalculée comme suspecte, et probablement bloquée ou re-challengée, ce qui est le comportement souhaité.
1444
+ // On pourrait même ajouter une pénalité ici si on le voulait.
1445
+ if (logger && challengeContext) {
1446
+ logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Invalid PoW solution', timestamp: Date.now() });
1447
+ } else if (logger && !challengeContext) {
1448
+ logger({ type: 'challenge_failed', deviceId: cookies?.device_id, reason: 'Nonce not found or expired', timestamp: Date.now() });
1449
+ }
1450
+ }
1451
+ // --- FIN DE LA LOGIQUE DE PRIORITÉ ---
1452
+
1453
+ // Resolve identity and check for persisted "condemned" status early.
1454
+ const { deviceId, deviceData, newCookie } = await resolveRequestIdentity(requestContext, this.securityConfig);
1455
+ const isNewDevice = !!newCookie;
1456
+
1276
1457
  if (deviceData?.condemned) {
1277
1458
  if (onDeviceCompromised) {
1278
1459
  onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
@@ -1280,30 +1461,25 @@ export class FingerprintEngine {
1280
1461
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1281
1462
  }
1282
1463
 
1283
- // (NOUVEAU) Vérifier si un nouveau device_id a été créé lors de cette requête
1284
- const isNewDevice = requestContext._newCookies?.some(c => c.name === 'device_id');
1285
-
1286
1464
  // The engine now works with the context directly, no more rawReq dependency here.
1287
1465
  const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1288
1466
  const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
1289
- const { requestPatternScore } = getRequestPatternScore(requestContext, deviceData, this.securityConfig.patterns);
1290
1467
  suspicionVector.honeypotScore = honeypotScore;
1291
- suspicionVector.requestPatternScore = requestPatternScore;
1468
+ // Le behaviorScore est déjà dans le vecteur de suspicion via getSuspicionVector
1292
1469
 
1293
- const finalScore =
1294
- suspicionVector.historyScore * (weights.historyScore || 0) +
1295
- suspicionVector.rotationScore * (weights.rotationScore || 0) +
1296
- suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) + suspicionVector.requestPatternScore * (weights.requestPatternScore || 0) +
1297
- suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0) +
1298
- honeypotScore * (weights.honeypotScore || 0);
1470
+ let finalScore = this.calculateFinalScore(suspicionVector);
1299
1471
 
1300
1472
  // Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
1301
1473
  // Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
1302
- const requiresChallengeForNewDevice = isNewDevice && finalScore < thresholds.low;
1303
-
1474
+ // NOUVEAU : Cette logique est maintenant configurable.
1475
+ const challengeNewDevices = this.securityConfig.challengeNewDevices === true;
1476
+ if (isNewDevice && finalScore < thresholds.low) {
1477
+ finalScore = thresholds.low;
1478
+ }
1304
1479
 
1305
1480
  const isBlocked = finalScore >= (thresholds.block || 95);
1306
1481
 
1482
+
1307
1483
  const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
1308
1484
  const isSuspiciousMedium = finalScore >= thresholds.medium;
1309
1485
  const isSuspicious = finalScore >= thresholds.low;
@@ -1311,24 +1487,12 @@ export class FingerprintEngine {
1311
1487
  // Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
1312
1488
  const suspicionFactor = isSuspicious
1313
1489
  ? Math.min(
1314
- 1,
1490
+ 1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
1315
1491
  (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
1316
1492
  )
1317
1493
  : 0;
1318
- const powCookie = cookies?.pow_clearance;
1319
- const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1320
1494
 
1321
- // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1322
- // A legitimate user only hits these endpoints via the challenge page itself.
1323
- if (pow_nonce && !isSuspicious) {
1324
- if (logger) {
1325
- logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
1326
- }
1327
- suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
1328
- // Recalculate score and block immediately.
1329
- const newFinalScore = finalScore - (honeypotScore * (weights.honeypotScore || 0)) + (100 * (weights.honeypotScore || 0));
1330
- return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
1331
- }
1495
+ const powCookie = cookies?.pow_clearance;
1332
1496
 
1333
1497
  // If the action is to block, we should still include the score and vector for logging/testing.
1334
1498
  if (isBlocked) {
@@ -1353,103 +1517,32 @@ export class FingerprintEngine {
1353
1517
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1354
1518
  }
1355
1519
 
1356
- if ((isSuspicious || requiresChallengeForNewDevice) && !isTicketValid(clientIp, powCookie)) {
1357
- // --- CHALLENGE SOLUTION HANDLING ---
1358
- if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1359
- let isValid = false,
1360
- // Retrieve the client-side secret associated with this nonce
1361
- clientSecret = await store.get(`secret:${pow_nonce}`),
1362
- ticket = null;
1363
- if (pow_type === "cpu_target") {
1364
- // Verify the new type
1365
- ticket = verifyCpuTargetPoWAndGenerateTicket(
1366
- clientIp,
1367
- pow_nonce,
1368
- pow_solution,
1369
- suspicionFactor, // Pass the analog factor
1370
- clientSecret,
1371
- );
1372
- isValid = ticket !== null; } else if (pow_type === "cpu_mem") {
1373
- // Verify combined challenge
1374
- const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
1375
- clientIp, pow_nonce, pow_solution_cpu, suspicionFactor, clientSecret
1376
- );
1377
- const minDifficulty = 16; // 16Mo
1378
- const maxDifficulty = 48; // 48Mo
1379
- const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
1380
- const memDifficulty = Math.round(minDifficulty + memActivationFactor * (maxDifficulty - minDifficulty));
1381
-
1382
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty, clientSecret);
1383
-
1384
- isValid = cpuTicket !== null && isMemValid;
1385
- if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
1386
- } else if (pow_type === "tsp") {
1387
- // Logic for TSP remains the same
1388
- // ...
1389
- }
1390
-
1391
- if (isValid) {
1392
- // The secret has been used, delete it to prevent replay.
1393
- if (clientSecret) {
1394
- await store.delete(`secret:${pow_nonce}`);
1395
- }
1396
-
1397
- if (!ticket) {
1398
- // If the ticket has not already been generated (CPU case)
1399
- const expiry = Date.now() + 3600000; // 1 heure
1400
- const signature = crypto
1401
- .createHmac("sha256", getPowSecret())
1402
- .update(`${clientIp}:${expiry}`)
1403
- .digest("hex");
1404
- ticket = `${expiry}:${signature}`;
1405
- }
1406
-
1407
- if (logger) {
1408
- logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: finalScore, challengeType: pow_type, timestamp: Date.now() });
1409
- }
1410
-
1411
- return {
1412
- action: 'redirect',
1413
- path: path,
1414
- score: finalScore,
1415
- vector: suspicionVector,
1416
- cookie: {
1417
- name: 'pow_clearance',
1418
- value: ticket,
1419
- options: {
1420
- httpOnly: true,
1421
- secure: this.isProduction,
1422
- maxAge: 3600000,
1423
- }
1424
- }
1425
- };
1520
+ if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
1521
+ // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1522
+ // A legitimate user only hits these endpoints via the challenge page itself.
1523
+ // If we see a pow_nonce on a request that IS suspicious but has no valid ticket,
1524
+ // AND it's not a legitimate response to a challenge we issued, it's a probe.
1525
+ const isChallengeResponse = query.pow_solution || (query.pow_solution_cpu && query.pow_solution_mem);
1526
+ if (pow_nonce && !isChallengeResponse) {
1527
+ if (logger) {
1528
+ logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
1426
1529
  }
1530
+ suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
1531
+ const newFinalScore = finalScore - (honeypotScore * (weights.honeypotScore || 0)) + (100 * (weights.honeypotScore || 0));
1532
+ return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
1427
1533
  }
1428
1534
 
1429
1535
  // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
1430
1536
  const nonce = crypto.randomBytes(16).toString("hex");
1431
1537
  const clientSecret = crypto.randomBytes(16).toString("hex");
1432
1538
 
1433
- // Store the secret with a short TTL (e.g., 5 minutes)
1434
- await store.set(`secret:${nonce}`, clientSecret, 300);
1435
-
1436
- // Associate the current challenge nonce with the device for trap URL verification later.
1437
- if (deviceData) {
1438
- deviceData.lastChallengeNonce = nonce; // No TTL, part of the main device object
1439
- await store.set(`device:${cookies.device_id}`, deviceData);
1440
- }
1441
-
1442
- if (logger) {
1443
- logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1444
- }
1445
-
1446
1539
  // LEVEL 3: CAPTCHA (the highest)
1447
1540
  if (isSuspiciousHigh) {
1448
1541
  // ... logic for TSP/Captcha challenge
1449
1542
  }
1450
1543
 
1451
1544
  // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
1452
- if (isSuspicious || requiresChallengeForNewDevice) { // Couvre à la fois low et medium
1545
+ if (isSuspicious) { // Couvre à la fois low et medium
1453
1546
  // Generate some trap URLs to embed in the challenge page.
1454
1547
  // These links are visually hidden but present in the DOM to trap bots.
1455
1548
  const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
@@ -1466,10 +1559,44 @@ export class FingerprintEngine {
1466
1559
  const maxMemDifficulty = 48; // 48Mo pour les plus suspects
1467
1560
  const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
1468
1561
 
1469
- // Always use the combined page, even if memory difficulty is 0 (it will be almost instant).
1470
- const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
1471
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
1472
- return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
1562
+ // Store the entire challenge context with a short TTL (e.g., 5 minutes)
1563
+ await store.set(`secret:${nonce}`, {
1564
+ clientSecret,
1565
+ cpuTarget: cpuChallengeDetails.target,
1566
+ memDifficulty: memDifficulty
1567
+ }, this.securityConfig.challengeTtl || 300); // NOUVEAU: TTL configurable (5min par défaut)
1568
+
1569
+ // Associate the current challenge nonce with the device for trap URL verification later.
1570
+ if (deviceData) {
1571
+ deviceData.lastChallengeNonce = nonce;
1572
+ await store.set(`device:${deviceId}`, deviceData); // Utiliser le deviceId résolu, pas celui des cookies
1573
+ }
1574
+
1575
+ if (logger) {
1576
+ logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1577
+ }
1578
+
1579
+ // Check if the request is an API request to return a JSON challenge
1580
+ const isApi = this.securityConfig.thresholds?.isApiRequest?.(requestContext);
1581
+
1582
+ if (isApi) {
1583
+ // For API clients, send a JSON response with challenge details.
1584
+ const challengePayload = {
1585
+ challenge: {
1586
+ type: 'cpu_mem',
1587
+ nonce: nonce,
1588
+ clientSecret: clientSecret, // The client needs this to solve the challenge
1589
+ cpuTarget: cpuChallengeDetails.target,
1590
+ memDifficulty: memDifficulty,
1591
+ }
1592
+ };
1593
+ return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: challengePayload };
1594
+ } else {
1595
+ // For browsers, send the HTML page.
1596
+ const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
1597
+ const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
1598
+ return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
1599
+ }
1473
1600
  }
1474
1601
  }
1475
1602
 
@@ -1521,8 +1648,9 @@ export class FingerprintEngine {
1521
1648
  vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
1522
1649
  vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
1523
1650
  vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
1524
- honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
1525
- requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0);
1651
+ honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
1652
+ requestPatternScore * (this.securityConfig.weights.requestPatternScore || 0) +
1653
+ vector.behaviorScore * (this.securityConfig.weights.behaviorScore || 0);
1526
1654
 
1527
1655
  if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
1528
1656
  if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
@@ -1661,10 +1789,11 @@ export const powMiddleware = (securityConfig) => {
1661
1789
  body: req.body,
1662
1790
  headers: req.headers,
1663
1791
  isStatic: isStaticResource(req.path),
1792
+ // Pass the original request object for the isApiRequest function
1793
+ rawReq: req,
1664
1794
  // Add the newly required properties for full decoupling
1665
1795
  rawHeaders: req.rawHeaders,
1666
1796
  // Pass the raw request object for advanced inspection (e.g., JA3)
1667
- rawReq: req,
1668
1797
  httpVersion: req.httpVersion,
1669
1798
  };
1670
1799
 
@@ -1685,7 +1814,11 @@ export const powMiddleware = (securityConfig) => {
1685
1814
  case 'block':
1686
1815
  return res.status(decision.status).send(decision.body);
1687
1816
 
1688
- case 'challenge':
1817
+ case 'challenge': // Gère à la fois les réponses HTML et JSON
1818
+ if (typeof decision.body === 'object' && decision.body !== null) {
1819
+ return res.status(decision.status).json(decision.body);
1820
+ }
1821
+ // Par défaut, envoie du HTML
1689
1822
  return res.status(decision.status).send(decision.body);
1690
1823
 
1691
1824
  case 'redirect':
@@ -1712,7 +1845,9 @@ export const __internal = {
1712
1845
  cyrb53, // Export for testing
1713
1846
  FingerprintBuilder, // Export for testing
1714
1847
  calculateTarget,
1848
+ determineOptimalTicketTtl,
1715
1849
  getRequestPatternScore, // Expose for testing
1850
+ getBehaviorScore, // Expose for testing
1716
1851
  };
1717
1852
 
1718
1853
  // --- THRESHOLD AUTO-TUNING SECTION ---