@anonympins/fingerprint 0.0.8 → 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
@@ -1,9 +1,11 @@
1
1
  // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
2
  import crypto from "node:crypto";
3
- import { parse } from "node:net";
3
+ import { BlockList } from "node:net";
4
4
  import dns from "node:dns/promises";
5
5
  import { Optimization } from "./library.js";
6
6
  import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
7
+ export { createRedisStore } from "./redis-store.js";
8
+ export { createMongoDbStore } from "./mongodb-store.js";
7
9
 
8
10
  /**
9
11
  * Retrieves the POW_SECRET from environment variables with appropriate checks.
@@ -381,6 +383,8 @@ export const verifyPoWAndGenerateTicket = (
381
383
  return `${expiry}:${signature}`;
382
384
  };
383
385
 
386
+
387
+
384
388
  /**
385
389
  * Verifies a memory PoW solution.
386
390
  * The server performs the same calculation to validate.
@@ -557,6 +561,28 @@ function getHoneypotScore(context, honeypotConfig = {}) {
557
561
  return { honeypotScore: 0 };
558
562
  }
559
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
+
560
586
  /**
561
587
  * Analyzes server-side request patterns for a given device to detect bot-like behavior.
562
588
  * This is a stateful check that looks for repetitive or unnaturally fast requests.
@@ -702,7 +728,7 @@ function verifyTrapUrl(path, signature, nonce) {
702
728
  /**
703
729
  * @typedef {object} IStore
704
730
  * @property {(key: string) => Promise<any>} get
705
- * @property {(key: string, value: any) => Promise<void>} set
731
+ * @property {(key: string, value: any, ttl?: number) => Promise<void>} set
706
732
  * @property {(key: string) => Promise<boolean>} has
707
733
  * @property {(key: string) => Promise<void>} delete
708
734
  */
@@ -713,8 +739,21 @@ function verifyTrapUrl(path, signature, nonce) {
713
739
  */
714
740
  const inMemoryStore = {
715
741
  _map: new Map(),
742
+ _timeouts: new Map(),
716
743
  async get(key) { return this._map.get(key); },
717
- async set(key, value) { this._map.set(key, value); },
744
+ async set(key, value, ttl) {
745
+ this._map.set(key, value);
746
+ // If a timeout already exists for this key, clear it.
747
+ if (this._timeouts.has(key)) {
748
+ clearTimeout(this._timeouts.get(key));
749
+ this._timeouts.delete(key);
750
+ }
751
+ // If a TTL is provided, set a timeout to delete the key.
752
+ if (ttl && ttl > 0) {
753
+ const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
754
+ this._timeouts.set(key, timeoutId);
755
+ }
756
+ },
718
757
  async has(key) { return this._map.has(key); },
719
758
  async delete(key) { this._map.delete(key); },
720
759
  };
@@ -737,7 +776,7 @@ export const configureStore = (externalStore) => {
737
776
  * @param {object} context - The request context.
738
777
  * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
739
778
  */
740
- async function resolveRequestIdentity(context) {
779
+ async function resolveRequestIdentity(context, securityConfig = {}) {
741
780
  const existingDeviceId = context.cookies?.device_id;
742
781
  const currentDeviceHash = getDeviceHash(context);
743
782
  let deviceId = existingDeviceId;
@@ -766,7 +805,9 @@ async function resolveRequestIdentity(context) {
766
805
  name: "device_id",
767
806
  value: deviceId,
768
807
  options: {
769
- 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 }),
770
811
  }
771
812
  };
772
813
 
@@ -851,7 +892,7 @@ async function getBehavioralIndicators(context, deviceData) {
851
892
  * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number, honeypotScore: number}>}
852
893
  */
853
894
  export const getSuspicionVector = async (context, securityConfig) => {
854
- const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
895
+ const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context, securityConfig);
855
896
 
856
897
  const clientIp = context.clientIp;
857
898
 
@@ -861,7 +902,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
861
902
  context._newCookies = context._newCookies || [];
862
903
  context._newCookies.push(newCookie);
863
904
  }
864
- await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
905
+ await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
865
906
 
866
907
  // Periodically clean up device data
867
908
  if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
@@ -873,8 +914,16 @@ export const getSuspicionVector = async (context, securityConfig) => {
873
914
  const behavioral = await getBehavioralIndicators(context, deviceData);
874
915
  const { headerAnomalyScore } = getHeaderAnomalies(context);
875
916
  // Calculate the inconsistency score here, separately.
876
- 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
877
925
 
926
+ const { requestPatternScore } = getRequestPatternScore(context, deviceData, securityConfig.patterns);
878
927
 
879
928
  // Save the updated device state to the store
880
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.
@@ -885,7 +934,8 @@ export const getSuspicionVector = async (context, securityConfig) => {
885
934
  if (Array.isArray(deviceData.ips)) {
886
935
  deviceData.ips = new Set(deviceData.ips);
887
936
  }
888
- return { ...behavioral, headerAnomalyScore, inconsistencyScore };
937
+ // Correction : Ajouter behaviorScore à l'objet retourné
938
+ return { ...behavioral, headerAnomalyScore, inconsistencyScore, behaviorScore, requestPatternScore };
889
939
  };
890
940
 
891
941
  // A residential user can change networks (home, 4G, public wifi).
@@ -1084,14 +1134,17 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
1084
1134
  * Verifies a PoW solution based on a target and generates a ticket.
1085
1135
  */
1086
1136
  export function verifyCpuTargetPoWAndGenerateTicket(
1087
- clientIp,
1137
+ clientIp, // This parameter is crucial and must be the actual client IP
1138
+ ticketMaxAge, // NOUVEAU: Durée de validité du ticket configurable
1088
1139
  nonce,
1089
1140
  solution,
1090
1141
  suspicionFactor,
1091
1142
  clientSecret, // Le secret est maintenant requis
1092
1143
  ) {
1093
1144
  const target = calculateTarget(suspicionFactor);
1094
- const message = clientSecret ? `${clientIp}:${nonce}:${solution}:${clientSecret}` : `${clientIp}:${nonce}:${solution}`;
1145
+ const message = clientSecret
1146
+ ? `${clientIp}:${nonce}:${solution}:${clientSecret}`
1147
+ : `${clientIp}:${nonce}:${solution}`;
1095
1148
  const hash = crypto
1096
1149
  .createHash("sha256")
1097
1150
  .update(message)
@@ -1101,8 +1154,8 @@ export function verifyCpuTargetPoWAndGenerateTicket(
1101
1154
  if (hashAsInt < target) {
1102
1155
  // The comparison is direct with native BigInts
1103
1156
  // The proof is valid, generate the ticket
1104
- const expiry = Date.now() + 3600000; // 1 heure
1105
- const signature = crypto
1157
+ const expiry = Date.now() + (ticketMaxAge || 3600000); // Utilise la durée passée ou un fallback.
1158
+ const signature = crypto
1106
1159
  .createHmac("sha256", getPowSecret())
1107
1160
  .update(`${clientIp}:${expiry}`)
1108
1161
  .digest("hex");
@@ -1118,14 +1171,95 @@ const staticExtensions = new RegExp(
1118
1171
  );
1119
1172
  const isStaticResource = (path) => staticExtensions.test(path);
1120
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
+
1121
1240
  // --- Middleware Proof-of-Work (Le péage) ---
1122
1241
  export class FingerprintEngine {
1123
1242
  constructor(securityConfig) {
1124
1243
  const isProduction = process.env.NODE_ENV === 'production';
1125
1244
  this.securityConfig = securityConfig;
1126
1245
  this.isProduction = isProduction;
1246
+ this._allowlist = this._buildAllowlist();
1127
1247
  }
1128
-
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
+ }
1129
1263
  /**
1130
1264
  * Checks if an IP address is in the static allowlist (IPs or CIDR ranges).
1131
1265
  * This is the fastest check and should be performed first.
@@ -1133,50 +1267,31 @@ export class FingerprintEngine {
1133
1267
  * @param {string} clientIp - The IP address of the client.
1134
1268
  * @returns {boolean} True if the IP is in the allowlist.
1135
1269
  */
1136
- _isIpInAllowlist(clientIp) {
1270
+ _buildAllowlist() {
1271
+ const blockList = new BlockList();
1137
1272
  const { whitelist = [] } = this.securityConfig;
1138
1273
  const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
1139
1274
 
1140
1275
  if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
1141
- return false;
1276
+ return blockList; // Retourne une liste vide
1142
1277
  }
1143
1278
 
1144
- const ip = parse(clientIp);
1145
- const ipVersion = ip.family;
1146
-
1147
1279
  for (const entry of allowlistRule.entries) {
1148
1280
  if (entry.includes('/')) { // CIDR range
1149
1281
  try {
1150
- const [range, prefixStr] = entry.split('/');
1151
- const prefix = parseInt(prefixStr, 10);
1152
- const rangeIp = parse(range);
1153
-
1154
- if (rangeIp.family !== ipVersion) continue;
1155
-
1156
- const ipBytes = ip.toBuffer();
1157
- const rangeBytes = rangeIp.toBuffer();
1158
- const mask = Buffer.alloc(ipBytes.length, 0xff);
1159
-
1160
- for (let i = 0; i < Math.floor(prefix / 8); i++) {
1161
- if (ipBytes[i] !== rangeBytes[i]) {
1162
- break; // Mismatch in full byte, move to next entry
1163
- }
1164
- }
1165
- const remainingBits = prefix % 8;
1166
- if (remainingBits > 0) {
1167
- const byteIndex = Math.floor(prefix / 8);
1168
- const bitmask = (0xff << (8 - remainingBits)) & 0xff;
1169
- if ((ipBytes[byteIndex] & bitmask) !== (rangeBytes[byteIndex] & bitmask)) {
1170
- continue; // Mismatch in partial byte
1171
- }
1172
- }
1173
- return true; // IP is in CIDR range
1174
- } catch (e) { continue; /* Ignore invalid CIDR entries */ }
1175
- } else if (entry === clientIp) { // Direct IP match
1176
- return true;
1282
+ const [address, prefix] = entry.split('/');
1283
+ blockList.addSubnet(address, parseInt(prefix, 10));
1284
+ } catch (e) {
1285
+ // Ignore les entrées CIDR invalides
1286
+ }
1287
+ } else { // Direct IP match
1288
+ blockList.addAddress(entry);
1177
1289
  }
1178
1290
  }
1179
- return false;
1291
+ return blockList;
1292
+ }
1293
+ _isIpInAllowlist(clientIp) {
1294
+ return this._allowlist.check(clientIp);
1180
1295
  }
1181
1296
  /**
1182
1297
  * Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
@@ -1224,26 +1339,27 @@ export class FingerprintEngine {
1224
1339
  const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
1225
1340
 
1226
1341
  if (!validHostname) {
1227
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
1342
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1228
1343
  return false;
1229
1344
  }
1230
1345
 
1231
1346
  // 2. Forward DNS lookup
1232
1347
  const addresses = await dns.resolve(validHostname);
1233
1348
  if (addresses.includes(clientIp)) {
1234
- await store.set(cacheKey, 'verified', 86400); // Cache success for 24h
1349
+ await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
1235
1350
  return true;
1236
1351
  }
1237
1352
  } catch (error) {
1238
1353
  // DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
1239
1354
  }
1240
1355
 
1241
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
1356
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1242
1357
  return false;
1243
1358
  }
1244
1359
 
1245
1360
  async processRequest(requestContext) {
1246
- const { clientIp, path, cookies, query, isStatic } = requestContext;
1361
+
1362
+ const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
1247
1363
  const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
1248
1364
  if (isStatic) {
1249
1365
  return { action: 'next', score: 0, vector: {} };
@@ -1257,13 +1373,13 @@ export class FingerprintEngine {
1257
1373
  const { pow_nonce } = query;
1258
1374
 
1259
1375
  // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1260
- // A legitimate user only hits these endpoints via the challenge page itself, which is only served to suspicious users.
1261
- // If we see a pow_nonce on a request that isn't (yet) considered suspicious, it's a bot.
1262
- // 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.
1263
1378
  if (pow_nonce) {
1264
1379
  const powCookie = cookies?.pow_clearance;
1265
1380
  if (!isTicketValid(clientIp, powCookie)) { // Only check if there's no valid ticket
1266
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.
1267
1383
  }
1268
1384
  }
1269
1385
 
@@ -1272,8 +1388,72 @@ export class FingerprintEngine {
1272
1388
  return { action: 'next', score: 0, vector: { whitelisted: 100, type: 'bot' } };
1273
1389
  }
1274
1390
 
1275
- // Check for persisted "condemned" status early.
1276
- 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
+
1277
1457
  if (deviceData?.condemned) {
1278
1458
  if (onDeviceCompromised) {
1279
1459
  onDeviceCompromised({ deviceId: cookies?.device_id, clientIp, reason: 'Previously condemned', score: 100, vector: { honeypotScore: 100 } });
@@ -1281,30 +1461,25 @@ export class FingerprintEngine {
1281
1461
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1282
1462
  }
1283
1463
 
1284
- // (NOUVEAU) Vérifier si un nouveau device_id a été créé lors de cette requête
1285
- const isNewDevice = requestContext._newCookies?.some(c => c.name === 'device_id');
1286
-
1287
1464
  // The engine now works with the context directly, no more rawReq dependency here.
1288
1465
  const suspicionVector = await __internal.getSuspicionVector(requestContext, this.securityConfig);
1289
1466
  const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
1290
- const { requestPatternScore } = getRequestPatternScore(requestContext, deviceData, this.securityConfig.patterns);
1291
1467
  suspicionVector.honeypotScore = honeypotScore;
1292
- suspicionVector.requestPatternScore = requestPatternScore;
1468
+ // Le behaviorScore est déjà dans le vecteur de suspicion via getSuspicionVector
1293
1469
 
1294
- const finalScore =
1295
- suspicionVector.historyScore * (weights.historyScore || 0) +
1296
- suspicionVector.rotationScore * (weights.rotationScore || 0) +
1297
- suspicionVector.headerAnomalyScore * (weights.headerAnomalyScore || 0) + suspicionVector.requestPatternScore * (weights.requestPatternScore || 0) +
1298
- suspicionVector.inconsistencyScore * (weights.inconsistencyScore || 0) +
1299
- honeypotScore * (weights.honeypotScore || 0);
1470
+ let finalScore = this.calculateFinalScore(suspicionVector);
1300
1471
 
1301
1472
  // Si c'est un nouvel appareil, on lui impose un challenge de base, même si son score est bas.
1302
1473
  // Cela augmente le coût pour les bots qui tentent de simplement supprimer leurs cookies.
1303
- const requiresChallengeForNewDevice = isNewDevice && finalScore < thresholds.low;
1304
-
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
+ }
1305
1479
 
1306
1480
  const isBlocked = finalScore >= (thresholds.block || 95);
1307
1481
 
1482
+
1308
1483
  const isSuspiciousHigh = finalScore >= thresholds.high && !isBlocked;
1309
1484
  const isSuspiciousMedium = finalScore >= thresholds.medium;
1310
1485
  const isSuspicious = finalScore >= thresholds.low;
@@ -1312,24 +1487,12 @@ export class FingerprintEngine {
1312
1487
  // Calculate an analog "suspicion factor" (0 to 1+) for progressive difficulty
1313
1488
  const suspicionFactor = isSuspicious
1314
1489
  ? Math.min(
1315
- 1,
1490
+ 1.5, // On autorise un dépassement pour rendre les challenges très difficiles si le score est très élevé
1316
1491
  (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
1317
1492
  )
1318
1493
  : 0;
1319
- const powCookie = cookies?.pow_clearance;
1320
- const { pow_type, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1321
1494
 
1322
- // Honeypot: Direct probing of challenge endpoints is highly suspicious.
1323
- // A legitimate user only hits these endpoints via the challenge page itself.
1324
- if (pow_nonce && !isSuspicious) {
1325
- if (logger) {
1326
- logger({ type: 'honeypot_probe', deviceId: cookies?.device_id, score: finalScore, path: path, timestamp: Date.now() });
1327
- }
1328
- suspicionVector.honeypotScore = 100; // Bot is probing. Max penalty.
1329
- // Recalculate score and block immediately.
1330
- const newFinalScore = finalScore - (honeypotScore * (weights.honeypotScore || 0)) + (100 * (weights.honeypotScore || 0));
1331
- return { action: 'block', status: 403, body: 'Forbidden', score: newFinalScore, vector: suspicionVector };
1332
- }
1495
+ const powCookie = cookies?.pow_clearance;
1333
1496
 
1334
1497
  // If the action is to block, we should still include the score and vector for logging/testing.
1335
1498
  if (isBlocked) {
@@ -1350,107 +1513,36 @@ export class FingerprintEngine {
1350
1513
  if (logger) {
1351
1514
  logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
1352
1515
  }
1353
- await store.set(`device:${cookies.device_id}`, deviceData);
1516
+ await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
1354
1517
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1355
1518
  }
1356
1519
 
1357
- if ((isSuspicious || requiresChallengeForNewDevice) && !isTicketValid(clientIp, powCookie)) {
1358
- // --- CHALLENGE SOLUTION HANDLING ---
1359
- if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1360
- let isValid = false,
1361
- // Retrieve the client-side secret associated with this nonce
1362
- clientSecret = await store.get(`secret:${pow_nonce}`),
1363
- ticket = null;
1364
- if (pow_type === "cpu_target") {
1365
- // Verify the new type
1366
- ticket = verifyCpuTargetPoWAndGenerateTicket(
1367
- clientIp,
1368
- pow_nonce,
1369
- pow_solution,
1370
- suspicionFactor, // Pass the analog factor
1371
- clientSecret,
1372
- );
1373
- isValid = ticket !== null; } else if (pow_type === "cpu_mem") {
1374
- // Verify combined challenge
1375
- const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
1376
- clientIp, pow_nonce, pow_solution_cpu, suspicionFactor, clientSecret
1377
- );
1378
- const minDifficulty = 16; // 16Mo
1379
- const maxDifficulty = 48; // 48Mo
1380
- const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
1381
- const memDifficulty = Math.round(minDifficulty + memActivationFactor * (maxDifficulty - minDifficulty));
1382
-
1383
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty, clientSecret);
1384
-
1385
- isValid = cpuTicket !== null && isMemValid;
1386
- if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
1387
- } else if (pow_type === "tsp") {
1388
- // Logic for TSP remains the same
1389
- // ...
1390
- }
1391
-
1392
- if (isValid) {
1393
- // The secret has been used, delete it to prevent replay.
1394
- if (clientSecret) {
1395
- await store.delete(`secret:${pow_nonce}`);
1396
- }
1397
-
1398
- if (!ticket) {
1399
- // If the ticket has not already been generated (CPU case)
1400
- const expiry = Date.now() + 3600000; // 1 heure
1401
- const signature = crypto
1402
- .createHmac("sha256", getPowSecret())
1403
- .update(`${clientIp}:${expiry}`)
1404
- .digest("hex");
1405
- ticket = `${expiry}:${signature}`;
1406
- }
1407
-
1408
- if (logger) {
1409
- logger({ type: 'challenge_solved', deviceId: cookies?.device_id, score: finalScore, challengeType: pow_type, timestamp: Date.now() });
1410
- }
1411
-
1412
- return {
1413
- action: 'redirect',
1414
- path: path,
1415
- score: finalScore,
1416
- vector: suspicionVector,
1417
- cookie: {
1418
- name: 'pow_clearance',
1419
- value: ticket,
1420
- options: {
1421
- httpOnly: true,
1422
- secure: this.isProduction,
1423
- maxAge: 3600000,
1424
- }
1425
- }
1426
- };
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() });
1427
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 };
1428
1533
  }
1429
1534
 
1430
1535
  // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
1431
1536
  const nonce = crypto.randomBytes(16).toString("hex");
1432
1537
  const clientSecret = crypto.randomBytes(16).toString("hex");
1433
1538
 
1434
- // Store the secret with a short TTL (e.g., 5 minutes)
1435
- await store.set(`secret:${nonce}`, clientSecret, 300);
1436
-
1437
- // Associate the current challenge nonce with the device for trap URL verification later.
1438
- if (deviceData) {
1439
- deviceData.lastChallengeNonce = nonce;
1440
- await store.set(`device:${cookies.device_id}`, deviceData);
1441
- }
1442
-
1443
- if (logger) {
1444
- logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1445
- }
1446
-
1447
1539
  // LEVEL 3: CAPTCHA (the highest)
1448
1540
  if (isSuspiciousHigh) {
1449
1541
  // ... logic for TSP/Captcha challenge
1450
1542
  }
1451
1543
 
1452
1544
  // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
1453
- if (isSuspicious || requiresChallengeForNewDevice) { // Couvre à la fois low et medium
1545
+ if (isSuspicious) { // Couvre à la fois low et medium
1454
1546
  // Generate some trap URLs to embed in the challenge page.
1455
1547
  // These links are visually hidden but present in the DOM to trap bots.
1456
1548
  const trapUrls = Array.from({ length: 3 }, () => generateTrapUrl(nonce));
@@ -1467,10 +1559,44 @@ export class FingerprintEngine {
1467
1559
  const maxMemDifficulty = 48; // 48Mo pour les plus suspects
1468
1560
  const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
1469
1561
 
1470
- // Always use the combined page, even if memory difficulty is 0 (it will be almost instant).
1471
- const trapContainer = `<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">${trapLinksHtml}</div>`;
1472
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret).replace('</body>', `${trapContainer}</body>`);
1473
- 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
+ }
1474
1600
  }
1475
1601
  }
1476
1602
 
@@ -1512,7 +1638,7 @@ export class FingerprintEngine {
1512
1638
  if (ipProfile.statelessCount > statelessLimit) {
1513
1639
  return `suspicious_high:${clientIp}`;
1514
1640
  }
1515
- await store.set(`ip:${clientIp}`, ipProfile);
1641
+ await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
1516
1642
 
1517
1643
  const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
1518
1644
  const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
@@ -1522,8 +1648,9 @@ export class FingerprintEngine {
1522
1648
  vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
1523
1649
  vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
1524
1650
  vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8) +
1525
- honeypotScore * (this.securityConfig.weights.honeypotScore || 0) +
1526
- 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);
1527
1654
 
1528
1655
  if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
1529
1656
  if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
@@ -1662,10 +1789,11 @@ export const powMiddleware = (securityConfig) => {
1662
1789
  body: req.body,
1663
1790
  headers: req.headers,
1664
1791
  isStatic: isStaticResource(req.path),
1792
+ // Pass the original request object for the isApiRequest function
1793
+ rawReq: req,
1665
1794
  // Add the newly required properties for full decoupling
1666
1795
  rawHeaders: req.rawHeaders,
1667
1796
  // Pass the raw request object for advanced inspection (e.g., JA3)
1668
- rawReq: req,
1669
1797
  httpVersion: req.httpVersion,
1670
1798
  };
1671
1799
 
@@ -1686,7 +1814,11 @@ export const powMiddleware = (securityConfig) => {
1686
1814
  case 'block':
1687
1815
  return res.status(decision.status).send(decision.body);
1688
1816
 
1689
- 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
1690
1822
  return res.status(decision.status).send(decision.body);
1691
1823
 
1692
1824
  case 'redirect':
@@ -1713,7 +1845,9 @@ export const __internal = {
1713
1845
  cyrb53, // Export for testing
1714
1846
  FingerprintBuilder, // Export for testing
1715
1847
  calculateTarget,
1848
+ determineOptimalTicketTtl,
1716
1849
  getRequestPatternScore, // Expose for testing
1850
+ getBehaviorScore, // Expose for testing
1717
1851
  };
1718
1852
 
1719
1853
  // --- THRESHOLD AUTO-TUNING SECTION ---