@anonympins/fingerprint 0.0.2 → 0.0.4

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.
Files changed (4) hide show
  1. package/README.md +57 -45
  2. package/fingerprint.js +245 -413
  3. package/library.js +1 -1
  4. package/package.json +47 -50
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # fingerprint
2
- ![](https://img.shields.io/github/actions/workflow/status/anonympins/fingerprint/ci.yml)
3
- ![](https://img.shields.io/github/v/release/anonympins/fingerprint)
4
- ![](https://img.shields.io/github/downloads/anonympins/fingerprint/total)
5
- ![](https://img.shields.io/github/watchers/anonympins/fingerprint)
6
- ![](https://img.shields.io/github/license/anonympins/fingerprint)
2
+ [![CI](https://img.shields.io/github/actions/workflow/status/anonympins/fingerprint/ci.yml)](https://github.com/anonympins/fingerprint/actions/workflows/ci.yml)
3
+ [![Release](https://img.shields.io/github/v/release/anonympins/fingerprint)](https://github.com/anonympins/fingerprint/releases)
4
+ [![License](https://img.shields.io/github/license/anonympins/fingerprint)](https://github.com/anonympins/fingerprint/blob/main/LICENSE)
5
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/w/anonympins/fingerprint)
6
+ [![Watchers](https://img.shields.io/github/watchers/anonympins/fingerprint)](https://github.com/anonympins/fingerprint/watchers)
7
7
 
8
8
  An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.
9
9
 
10
10
  ## How It Works
11
11
 
12
- This system is designed to identify and slow down bots and automated scripts by evaluating the "suspicion" level of each incoming request. Instead of outright blocking, it imposes challenges with a difficulty proportional to the suspicion score, penalizing bots without significantly impacting legitimate users.
12
+ This system identifies and slows down bots and automated scripts by evaluating the "suspicion" level of each incoming request. Instead of outright blocking, it imposes challenges with a difficulty proportional to the suspicion score, penalizing bots without significantly impacting legitimate users.
13
13
 
14
14
  The process unfolds in three steps:
15
15
 
@@ -20,25 +20,19 @@ The process unfolds in three steps:
20
20
  * **IP Behavior**: An excessive number of different devices seen from the same IP, or a single device using a large number of IPs (proxy rotation).
21
21
  * **Inconsistency**: A low similarity score between the current fingerprint and the initial one associated with the `device_id` (cookie theft detection).
22
22
  3. **Dynamic Challenge**: If the suspicion score exceeds a certain threshold, a challenge is presented to the user. The difficulty and type of challenge depend on the score:
23
- * **Level 1 (Low Suspicion)**: CPU-based PoW challenge (SHA-256).
24
- * **Level 2 (Medium Suspicion)**: Memory-intensive PoW challenge.
25
- * **Level 3 (High Suspicion)**: Complex challenge (e.g., TSP - Traveling Salesperson Problem) or a CAPTCHA.
23
+ * **Low to Medium Suspicion**: A combined CPU and Memory Proof-of-Work (PoW) challenge is issued. The difficulty of both the CPU (hash calculation) and Memory (allocation and computation) components scales progressively with the suspicion score. For low scores, the memory challenge is negligible, making it primarily a CPU task.
24
+ * **High Suspicion**: For the most suspicious requests, the system issues a high-difficulty combined CPU/Memory challenge. The architecture allows for plugging in more complex challenges like CAPTCHAs if needed.
26
25
 
27
- Once the challenge is solved, a clearance "ticket" is issued via a cookie, exempting the user from new challenges for a set period.
26
+ Once the challenge is solved, a clearance "ticket" is issued via a secure cookie, exempting the user from new challenges for a set period.
28
27
 
29
28
  ## Features
30
29
 
31
30
  - **Multi-Factor Fingerprinting**: Combines client-side data (`hardwareConcurrency`, `deviceMemory`, `screen`, `canvas`, `webgl`) and server-side data (`User-Agent`, `Client-Hints`).
32
- - **Weighted Suspicion Engine**: Calculates a score based on behavioral and technical indicators.
33
- - **Variable-Difficulty Proof-of-Work Challenges**:
34
- - `cpu_target`: An "analog" CPU challenge where difficulty is finely tuned to the suspicion score.
35
- - `memory`: A challenge that allocates an amount of memory proportional to the suspicion level.
36
- - `tsp`: An optimization challenge (Traveling Salesperson Problem) for the most suspicious cases.
37
31
  - **Secure Ticket System**: Uses HMAC-SHA256 signatures to validate clearances and prevent tampering.
38
32
  - **Pluggable Datastore**: Supports external datastores like Redis for state persistence and scalability across multiple server instances.
39
- - **Express.js Middleware**: Easy integration into an Express application with `powMiddleware`.
33
+ - **Express.js Middleware**: Easy integration into an Express application with `powMiddleware`. The datastore must support setting a Time-To-Live (TTL) for challenge secrets.
40
34
  - **Timing Attack Protection**: Uses `crypto.timingSafeEqual` for secure ticket validation.
41
- - **Automatic Threshold Tuning**: (Optional) Includes a genetic algorithm-based optimizer (`startThresholdAutoTuning`) that analyzes real traffic to dynamically adjust suspicion thresholds (`low`, `medium`, `high`), improving bot detection accuracy and reducing false positives over time.
35
+ - **Automatic Threshold Tuning**: Includes a genetic algorithm-based optimizer (`startThresholdAutoTuning`) that analyzes real traffic to dynamically adjust suspicion thresholds (`low`, `medium`, `high`), improving bot detection accuracy and reducing false positives over time.
42
36
 
43
37
  ## Installation and Usage
44
38
 
@@ -58,13 +52,11 @@ export POW_SECRET="your_secret_key_of_at_least_32_characters"
58
52
 
59
53
  ### Integration Example
60
54
 
61
- For the `powMiddleware` to work, it needs a configuration defining the weights of suspicion indicators and the challenge trigger thresholds.
55
+ The `powMiddleware` requires a configuration object defining the weights of suspicion indicators and the challenge trigger thresholds.
62
56
 
63
57
  ```javascript
64
58
  import express from 'express';
65
59
  import cookieParser from 'cookie-parser';
66
- // The `configurePow` function is a conceptual example. In the actual implementation,
67
- // you would pass the configuration to the middleware, for example, via a factory function.
68
60
  import { powMiddleware /*, configurePow */ } from './fingerprint.js'; // Adjust the path
69
61
 
70
62
  const app = express();
@@ -81,27 +73,34 @@ const securityConfig = {
81
73
  },
82
74
  thresholds: {
83
75
  low: 20, // Score from which a CPU challenge is issued
84
- medium: 45, // Score for a Memory challenge
85
- high: 75 // Score for a complex challenge (TSP/Captcha)
76
+ medium: 45, // Score for a more difficult combined CPU/Memory challenge
77
+ high: 75, // Score for a very difficult challenge
78
+ block: 95, // Score above which the request is blocked outright (HTTP 403)
79
+ isStaticResource: (req) => req.path.startsWith('/static/') // Optional: Custom function to identify static resources
86
80
  }
87
81
  };
88
82
 
89
- // In a real-world scenario, you would configure the middleware.
90
- // For example: const configuredPowMiddleware = createPowMiddleware(securityConfig);
83
+ // Create an instance of the middleware with your security configuration.
91
84
  const powMiddlewareInstance = powMiddleware(securityConfig);
92
85
 
93
86
  // Enable trust proxy if your app is behind a reverse proxy (Nginx, etc.)
94
87
  // to correctly retrieve the client's IP.
95
88
  app.set('trust proxy', 1);
96
89
 
97
- // Apply the protection middleware to all routes or to specific routes.
98
- // You would use the configured middleware here.
90
+ // Apply the protection middleware to all routes or to specific ones.
99
91
  app.use(powMiddlewareInstance);
100
92
 
101
93
  app.get('/', (req, res) => {
102
94
  res.send('Welcome to the protected page!');
103
95
  });
104
96
 
97
+ // Example of accessing the suspicion score in a subsequent middleware or route.
98
+ // The `fingerprint` object is attached to the request object by the middleware.
99
+ app.use((req, res, next) => {
100
+ console.log(`Request from ${req.ip} has a suspicion score of: ${req.fingerprint?.score}`);
101
+ next();
102
+ });
103
+
105
104
  app.listen(3000, () => console.log('Server started on port 3000'));
106
105
  ```
107
106
 
@@ -111,7 +110,7 @@ In addition to the main middleware, several functions are exported to allow for
111
110
 
112
111
  ### Main Functions
113
112
 
114
- #### `powMiddleware(req, res, next)`
113
+ #### `powMiddleware(securityConfig)`
115
114
  The main Express middleware. It orchestrates identification, suspicion calculation, and challenge issuance. It is the main entry point of the library.
116
115
 
117
116
  #### `configureStore(store)`
@@ -119,7 +118,7 @@ Allows replacing the in-memory store with an external datastore (like Redis) for
119
118
 
120
119
  ```javascript
121
120
  import { configureStore } from './fingerprint.js';
122
- import { createRedisStore } from './redis-store.js'; // Assuming you have a redis store implementation
121
+ import { createRedisStore } from './redis-store.js'; // Assuming a redis store implementation exists
123
122
 
124
123
  const redisStore = createRedisStore(process.env.REDIS_URL);
125
124
  configureStore(redisStore);
@@ -154,7 +153,7 @@ app.use(async (req, res, next) => {
154
153
  #### `isTicketValid(ip, ticket)`
155
154
  Checks the validity of a `pow_clearance` cookie. Returns `true` if the ticket is present, not expired, and correctly signed for the given IP.
156
155
 
157
- #### `FingerprintBuilder` (Class)
156
+ #### `FingerprintBuilder`
158
157
  A class for building granular server-side fingerprints.
159
158
 
160
159
  ```javascript
@@ -166,7 +165,22 @@ const fp = builder.toString(); // "os:hash1|ua:hash2"
166
165
 
167
166
  #### `getDeviceFingerprint()`
168
167
  *Client-side function only.* Generates a detailed browser fingerprint using APIs like Canvas, WebGL, etc.
168
+ This is the primary function for client-side identification.
169
+
170
+ #### `generateRequestSignature(payload)`
171
+ *Client-side function only.* Creates a signature for an outgoing request. It combines the device fingerprint with a hash of the request's `payload`. This can be used on the server-side to verify that a request comes from a recognized device and that its payload has not been trivially altered.
172
+
173
+ ```javascript
174
+ // On the client
175
+ const signature = generateRequestSignature({ action: 'update', id: 123 });
176
+ // Send signature in headers...
177
+ ```
169
178
 
179
+ #### `generateClientSideSignature(payload, secret)`
180
+ *Client-side function only.* Generates a secure HMAC-SHA256 signature for a given `payload` using a `secret`.
181
+ **Security Note:** This function is powerful but should be used with caution. The `secret` must be managed securely. It is typically used with a temporary, single-use secret provided by the server for a specific action, rather than a long-lived shared secret embedded in the client-side code.
182
+
183
+ ---
170
184
  ## Advanced Features
171
185
 
172
186
  ### Architecture: `FingerprintEngine`
@@ -209,9 +223,8 @@ const server = http.createServer(async (req, res) => {
209
223
  clientIp: req.socket.remoteAddress,
210
224
  path: req.url.split('?')[0],
211
225
  cookies: {}, // Parse cookies from req.headers.cookie
212
- query: {}, // Parse query string from req.url
226
+ query: new URL(req.url, `http://${req.headers.host}`).searchParams,
213
227
  headers: req.headers,
214
- isStatic: /\.(js|css|png)$/.test(req.url),
215
228
  rawReq: req, // Pass the raw request
216
229
  rawRes: res, // Pass the raw response for cookie setting
217
230
  };
@@ -219,6 +232,10 @@ const server = http.createServer(async (req, res) => {
219
232
  // 2. Process and get a decision
220
233
  const decision = await engine.processRequest(requestContext);
221
234
 
235
+ // The decision object now contains the score and the raw suspicion vector.
236
+ // You can use it for logging or custom logic.
237
+ console.log(`Request from ${requestContext.clientIp} processed with score: ${decision.score}`);
238
+
222
239
  // 3. Act on the decision
223
240
  if (decision.action === 'challenge') {
224
241
  res.writeHead(decision.status, { 'Content-Type': 'text/html' });
@@ -244,35 +261,30 @@ Manually setting the `low`, `medium`, and `high` thresholds can be challenging.
244
261
 
245
262
  1. **Enable Logging**: The auto-tuner needs data. You must provide a `logger` function in your security configuration. This function will be called for significant events (`challenge_issued`, `challenge_solved`, etc.).
246
263
 
247
- 2. **Start the Tuner**: Call `startThresholdAutoTuning` with your live security configuration and the array where logs are stored.
264
+ 2. **Enable Auto-tuning**: Add an `autotuning` property to your security configuration. The middleware will automatically start the tuning process.
248
265
 
249
266
  ```javascript
250
- import { powMiddleware, startThresholdAutoTuning } from './fingerprint.js';
267
+ import { powMiddleware } from './fingerprint.js';
251
268
 
252
269
  // Array to store traffic analysis data. In a real application, this could be
253
270
  // a more robust logging system.
254
271
  const trafficData = [];
255
272
 
256
273
  const securityConfig = {
257
- weights: { /* ... your weights ... */ },
274
+ weights: { /* ... */ },
258
275
  thresholds: {
259
276
  low: 20, // Initial values, will be optimized
260
277
  medium: 45,
261
278
  high: 75
262
279
  },
263
- // The logger is required for auto-tuning
264
- logger: (log) => trafficData.push(log)
280
+ logger: (log) => trafficData.push(log), // The logger is required for auto-tuning
281
+ autotune: {
282
+ trafficData: trafficData, // The data source for the algorithm
283
+ interval: 1800000, // Optimization cycle every 30 minutes (optional)
284
+ minDataPoints: 200 // Minimum requests before starting optimization (optional)
285
+ }
265
286
  };
266
287
 
267
- // Start the background optimization process.
268
- // The `securityConfig.thresholds` object will be mutated with optimized values.
269
- startThresholdAutoTuning({
270
- securityConfig: securityConfig, // The config object to be updated
271
- trafficData: trafficData, // The data source for the algorithm
272
- interval: 1800000, // Optimization cycle every 30 minutes
273
- minDataPoints: 200 // Minimum requests before starting optimization
274
- });
275
-
276
288
  const powMiddlewareInstance = powMiddleware(securityConfig);
277
289
  app.use(powMiddlewareInstance);
278
290
  ```
package/fingerprint.js CHANGED
@@ -1,258 +1,51 @@
1
1
  // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
2
  import crypto from "node:crypto";
3
3
  import { Optimization } from "./library.js";
4
+ import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
4
5
 
5
- const POW_SECRET = process.env.POW_SECRET;
6
-
7
- if (!POW_SECRET && process.env.NODE_ENV === 'production') {
8
- throw new Error('POW_SECRET environment variable is not set. This is required for production.');
9
- } else if (!POW_SECRET) {
10
- console.warn('Warning: POW_SECRET environment variable not set. Using a default, insecure secret for development.');
11
- }
12
6
  /**
13
- * cyrb53 hash algorithm (fast with a low collision rate). Exported for reuse.
7
+ * Retrieves the POW_SECRET from environment variables with appropriate checks.
8
+ * @returns {string} The secret key.
14
9
  */
15
- export const cyrb53 = (str, seed = 0) => {
16
- let h1 = 0xdeadbeef ^ seed,
17
- h2 = 0x41c6ce57 ^ seed;
18
- for (let i = 0, ch; i < str.length; i++) {
19
- ch = str.charCodeAt(i);
20
- h1 = Math.imul(h1 ^ ch, 2654435761);
21
- h2 = Math.imul(h2 ^ ch, 1597334677);
10
+ const getPowSecret = () => {
11
+ const secret = process.env.POW_SECRET;
12
+ if (!secret && process.env.NODE_ENV === 'production') {
13
+ throw new Error('POW_SECRET environment variable is not set. This is required for production.');
22
14
  }
23
- h1 =
24
- Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
25
- Math.imul(h2 ^ (h2 >>> 13), 3266489909);
26
- h2 =
27
- Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
28
- Math.imul(h1 ^ (h1 >>> 13), 3266489909);
29
- return 4294967296 * (2097151 & h2) + (h1 >>> 0);
15
+ return secret || "fallback-dev-secret-32-chars-minimum";
30
16
  };
31
17
 
32
18
  /**
33
- * Class to build a composite fingerprint (Multi-Hash).
34
- * Output format: "grp1:hash1|grp2:hash2|grp3:hash3"
19
+ * Creates a stable hash based on device characteristics, independent of the IP.
20
+ * This is our "level 2 fingerprint".
21
+ * @param {object} context - The request context.
22
+ * @returns {string} A hash representing the device.
35
23
  */
36
- export class FingerprintBuilder {
37
- constructor() {
38
- this.components = new Map();
39
- }
40
-
41
- /**
42
- * Adds a component to the global hash.
43
- * @param {string} group - The group name (e.g., 'hw', 'screen', 'geo')
44
- * @param {string|number|boolean} value - The raw value to be hashed
45
- */
46
- add(group, value) {
47
- if (value === undefined || value === null) return this;
48
- // Hash the value individually to anonymize it and reduce its size
49
- this.components.set(group, cyrb53(String(value)));
50
- return this;
51
- }
52
-
53
- /**
54
- * Generates the final signature string.
55
- * Sorts keys to ensure a deterministic order.
56
- */
57
- toString() {
58
- return Array.from(this.components.entries())
59
- .sort((a, b) => a[0].localeCompare(b[0])) // Tri alphabétique des clés
60
- .map(([key, hash]) => `${key}:${hash}`)
61
- .join("|");
62
- }
63
-
64
- /**
65
- * Compares two fingerprints and returns a similarity score (0 to 1).
66
- * Uses weights to give more importance to strong invariants (Canvas, GPU).
67
- * @param {string} fpString1 - Fingerprint A
68
- * @param {string} fpString2 - Fingerprint B
69
- */
70
- static compare(fpString1, fpString2) {
71
- if (!fpString1 || !fpString2) return 0;
72
-
73
- const parse = (str) => {
74
- const map = new Map();
75
- str.split("|").forEach((part) => {
76
- const [k, v] = part.split(":");
77
- if (k && v) map.set(k, v);
78
- });
79
- return map;
80
- };
81
-
82
- const map1 = parse(fpString1);
83
- const map2 = parse(fpString2);
84
-
85
- // "Veracity" weights (Entropy/Stability)
86
- const weights = {
87
- cvs: 4.0, // Canvas: Very high entropy (Unique rendering)
88
- gpu: 3.0, // GPU: High entropy (Specific hardware)
89
- hw: 1.5, // Hardware: Medium entropy
90
- scr: 1.0, // Screen: Medium
91
- geo: 0.5, // Geo: Low (VPN/Travel)
92
- os: 0.5, // OS: Low (Generic)
93
- bot: 0.0, // Bot: Informational
94
- };
95
-
96
- let weightedMatches = 0;
97
- let totalWeight = 0;
98
-
99
- const allKeys = new Set([...map1.keys(), ...map2.keys()]);
100
-
101
- allKeys.forEach((key) => {
102
- if (map1.has(key) && map2.has(key)) {
103
- const weight = weights[key] || 1.0;
104
- totalWeight += weight;
105
-
106
- if (map1.get(key) === map2.get(key)) {
107
- weightedMatches += weight;
108
- }
109
- }
110
- });
111
-
112
- return totalWeight === 0 ? 0 : weightedMatches / totalWeight;
113
- }
24
+ function getHeaderSignature(context) {
25
+ if (!context.rawHeaders) return '';
26
+ const headerKeys = [];
27
+ for (let i = 0; i < context.rawHeaders.length; i += 2) {
28
+ headerKeys.push(context.rawHeaders[i]);
29
+ }
30
+ return cyrb53(headerKeys.join(','));
114
31
  }
32
+ export function getDeviceHash(context) {
33
+ // Prioritize the rich client-side fingerprint if provided.
34
+ const clientFp = context.headers['x-device-fingerprint'];
35
+ if (clientFp && typeof clientFp === 'string' && clientFp.includes('cvs:')) {
36
+ // Basic validation to ensure it looks like our client-side fingerprint.
37
+ return clientFp;
38
+ }
115
39
 
116
- // Cache to avoid recalculating constants (Hardware, etc.)
117
- let cachedBuilder = null;
118
-
119
- /**
120
- * Generates the fingerprint of the current device.
121
- */
122
- export const getDeviceFingerprint = () => {
123
- // NOTE: This is client-side code and should be in a separate file.
124
- // It will not work in a Node.js environment.
125
- // The presence of `window` and `document` confirms this.
126
-
127
- if (typeof window === "undefined") return "server-side";
128
-
129
- if (!cachedBuilder) {
130
- const nav = window.navigator;
131
- const screen = window.screen;
132
-
133
- cachedBuilder = new FingerprintBuilder();
134
-
135
- // 1. Hardware (Very stable): Cores, RAM, GPU (if available via canvas), Touch
136
- cachedBuilder.add(
137
- "hw",
138
- `${nav.hardwareConcurrency}_${nav.deviceMemory}_${nav.maxTouchPoints}`,
139
- );
140
-
141
- // 2. Geo/Locale (Stable except for travel/VPN): Timezone, Language
142
- cachedBuilder.add(
143
- "geo",
144
- `${Intl.DateTimeFormat().resolvedOptions().timeZone}_${nav.language}_${new Date().getTimezoneOffset()}`,
145
- );
146
-
147
- // 3. Screen (Stable except for monitor/zoom changes): Dimensions, ColorDepth
148
- // Note: We use availWidth/Height which excludes the taskbar, sometimes more unique
149
- cachedBuilder.add(
150
- "scr",
151
- `${screen.width}x${screen.height}_${screen.colorDepth}`,
152
- );
153
-
154
- // 4. Platform (Stable): OS, Engine
155
- cachedBuilder.add("os", nav.platform);
156
-
157
- // 5. Graphics (WebGL Vendor/Renderer) - Strong hardware invariant
158
- try {
159
- const canvas = document.createElement("canvas");
160
- const gl =
161
- canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
162
- if (gl) {
163
- const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
164
- if (debugInfo) {
165
- const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
166
- const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
167
- cachedBuilder.add("gpu", `${vendor}_${renderer}`);
168
- }
169
- }
170
- } catch (e) {}
171
-
172
- // 6. Canvas Fingerprinting (Rendering quirks) - Adds ~5-10% uniqueness
173
- // Exploits micro-differences in anti-aliasing and font rendering
174
- try {
175
- const canvas = document.createElement("canvas");
176
- const ctx = canvas.getContext("2d");
177
- if (ctx) {
178
- canvas.width = 200;
179
- canvas.height = 50;
180
- ctx.textBaseline = "alphabetic";
181
- ctx.font = "14px 'Arial'";
182
- ctx.fillStyle = "#f60";
183
- ctx.fillRect(125, 1, 62, 20);
184
- ctx.fillStyle = "#069";
185
- ctx.fillText("Primals", 2, 15);
186
- ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
187
- ctx.fillText("Primals", 4, 17);
188
- cachedBuilder.add("cvs", canvas.toDataURL());
189
- }
190
- } catch (e) {}
191
-
192
- // 7. Bot Detection (Hidden indicator)
193
- if (nav.webdriver) cachedBuilder.add("bot", "true");
194
- }
195
-
196
- // Return a copy to allow adding dynamic fields if needed without polluting the cache
197
- return cachedBuilder.toString();
198
- };
199
-
200
- /**
201
- * Generates a request signature including the context.
202
- * @param {object} payload
203
- */
204
- export const generateRequestSignature = (payload = {}) => {
205
- const deviceFp = getDeviceFingerprint();
206
-
207
- // Create a temporary builder that inherits from deviceFp
208
- // Note: Here we keep it simple, just concatenating the payload hash
209
- const sortedPayload = Object.keys(payload)
210
- .sort()
211
- .map((k) => `${k}=${payload[k]}`)
212
- .join("&");
213
- const payloadHash = cyrb53(sortedPayload);
214
-
215
- return `${deviceFp}|req:${payloadHash}`;
216
- };
217
-
218
- /**
219
- * Generates an HMAC-SHA256 signature for combat data.
220
- * @param {object} payload - The data to sign (e.g., { opponentId, victory, damageDealt }).
221
- * @param {string} secret - The shared secret key.
222
- * @returns {Promise<string>} The hexadecimal signature.
223
- */
224
- export const generateCombatSignature = async (payload, secret) => {
225
- // NOTE: This is client-side code using the Web Crypto API (`window.crypto`).
226
- // It should be moved to a client-side script file.
227
-
228
- // 1. Create a stable string from the payload.
229
- const sortedPayload = Object.keys(payload)
230
- .sort()
231
- .map((k) => `${k}=${payload[k]}`)
232
- .join("&");
233
-
234
- // 2. Use the Web Crypto API for HMAC
235
- const encoder = new TextEncoder();
236
- const key = await window.crypto.subtle.importKey(
237
- "raw",
238
- encoder.encode(secret),
239
- { name: "HMAC", hash: "SHA-256" },
240
- false,
241
- ["sign"],
242
- );
243
- const signatureBuffer = await window.crypto.subtle.sign(
244
- "HMAC",
245
- key,
246
- encoder.encode(sortedPayload),
247
- );
248
-
249
- // 3. Convert the signature to a hexadecimal string.
250
- const hashArray = Array.from(new Uint8Array(signatureBuffer));
251
- const hexString = hashArray
252
- .map((b) => b.toString(16).padStart(2, "0"))
253
- .join("");
254
- return hexString;
255
- };
40
+ // Fallback to server-side only fingerprinting if the header is missing.
41
+ const srv = new FingerprintBuilder();
42
+ srv.add("ua", context.headers["user-agent"]);
43
+ if (context.headers["sec-ch-ua-platform"])
44
+ srv.add("os", context.headers["sec-ch-ua-platform"]);
45
+ if (context.headers["sec-ch-ua"]) srv.add("ch", context.headers["sec-ch-ua"]);
46
+ srv.add("h_ord", getHeaderSignature(context));
47
+ return srv.toString();
48
+ }
256
49
 
257
50
  /**
258
51
  * Generates the HTML content for a TSP (Traveling Salesperson Problem) challenge.
@@ -531,7 +324,7 @@ export const verifyPoWAndGenerateTicket = (
531
324
  // 2. Generate an HMAC ticket so the client doesn't have to do it again for 1 hour
532
325
  const expiry = Date.now() + 3600000; // 1 heure
533
326
  const signature = crypto
534
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
327
+ .createHmac("sha256", getPowSecret())
535
328
  .update(`${ip}:${expiry}`)
536
329
  .digest("hex");
537
330
 
@@ -542,18 +335,22 @@ export const verifyPoWAndGenerateTicket = (
542
335
  * Verifies a memory PoW solution.
543
336
  * The server performs the same calculation to validate.
544
337
  */
545
- export const verifyMemoryPoW = (nonce, solution, difficulty = 16) => {
338
+ export const verifyMemoryPoW = (nonce, solution, difficulty = 16, clientSecret) => {
546
339
  const size = difficulty * 1024 * 1024;
547
340
  const iterations = size / 16;
548
341
  const buffer = new Uint32Array(size / 4);
549
- let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
342
+ const seed = clientSecret ? `${nonce}:${clientSecret}` : nonce;
343
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
344
+
550
345
  for (let i = 0; i < buffer.length; i++) {
551
346
  buffer[i] = h = Math.imul(h ^ i, 1597334677);
552
347
  }
348
+
553
349
  let finalHash = 0;
350
+ let addr = buffer.length > 0 ? buffer[0] % buffer.length : 0;
554
351
  for (let i = 0; i < iterations; i++) {
555
- const addr = buffer[i % buffer.length] % buffer.length;
556
- finalHash ^= buffer[addr];
352
+ addr = buffer[addr] % buffer.length;
353
+ finalHash ^= addr;
557
354
  }
558
355
  return finalHash === parseInt(solution, 10);
559
356
  };
@@ -562,58 +359,42 @@ export const isTicketValid = (ip, ticket) => {
562
359
  const [expiry, sig] = ticket.split(":");
563
360
  if (!expiry || !sig || Date.now() > parseInt(expiry, 10)) return false;
564
361
  const expectedSig = crypto
565
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
362
+ .createHmac("sha256", getPowSecret())
566
363
  .update(`${ip}:${expiry}`)
567
364
  .digest("hex");
568
365
 
569
366
  // Use timingSafeEqual to prevent timing attacks
570
- return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig));
367
+ try {
368
+ return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expectedSig, 'hex'));
369
+ } catch (e) {
370
+ // This can happen if the buffers have different lengths, which is a failure case.
371
+ return false;
372
+ }
571
373
  };
572
374
 
573
- /**
574
- * Creates a stable hash based on device characteristics, independent of the IP.
575
- * This is our "level 2 fingerprint".
576
- * @param {object} req - The Express request object.
577
- * @returns {string} A hash representing the device.
578
- */
579
- function getDeviceHash(req) {
580
- const srv = new FingerprintBuilder();
581
- srv.add("ua", req.headers["user-agent"]);
582
- if (req.headers["sec-ch-ua-platform"])
583
- srv.add("os", req.headers["sec-ch-ua-platform"]);
584
- if (req.headers["sec-ch-ua"]) srv.add("ch", req.headers["sec-ch-ua"]);
585
- return srv.toString(); // Returns the full fingerprint string for detailed comparison.
586
- }
587
375
 
588
376
  /**
589
377
  * Calculates suspicion indicators related to HTTP header anomalies.
590
- * @param {object} req - The Express request object.
378
+ * @param {object} context - The request context.
591
379
  * @returns {{headerAnomalyScore: number}}
592
380
  */
593
- function getHeaderAnomalies(req, consistencyScore) {
594
- // FIX: consistencyScore est maintenant passé
381
+ function getHeaderAnomalies(context) {
595
382
  let anomalyScore = 0;
596
383
  // Strong penalty if User-Agent is missing or very short (sign of a simple script)
597
- if (!req.headers["user-agent"] || req.headers["user-agent"].length < 10) {
384
+ if (!context.headers["user-agent"] || context.headers["user-agent"].length < 10) {
598
385
  anomalyScore += 60;
599
386
  }
600
387
  // Penalty if Accept-Language header is missing
601
- if (!req.headers["accept-language"]) {
388
+ if (!context.headers["accept-language"]) {
602
389
  anomalyScore += 25;
603
390
  }
604
391
  // Penalty for HTTP/1.0 requests, often used by old tools or bots
605
- if (req.httpVersion === "1.0") {
392
+ if (context.httpVersion === "1.0") {
606
393
  anomalyScore += 15;
607
394
  }
608
395
 
609
- // NEW: Inconsistency score (stolen cookie?)
610
- // If the consistency score is low, add a massive penalty.
611
- // A score of 0.2 means a huge difference.
612
- const inconsistencyScore = Math.max(0, (1 - consistencyScore) * 200);
613
-
614
396
  return {
615
397
  headerAnomalyScore: Math.min(100, anomalyScore),
616
- inconsistencyScore: Math.min(100, inconsistencyScore),
617
398
  };
618
399
  }
619
400
 
@@ -627,7 +408,7 @@ function getHeaderAnomalies(req, consistencyScore) {
627
408
 
628
409
  /**
629
410
  * Default in-memory store implementation.
630
- * @type {IStore}
411
+ * @type {IStore}
631
412
  */
632
413
  const inMemoryStore = {
633
414
  _map: new Map(),
@@ -652,16 +433,16 @@ export const configureStore = (externalStore) => {
652
433
  /**
653
434
  * Orchestrates request identification using a persistent anchor (cookie)
654
435
  * and fingerprint verification.
655
- * @param {object} req - The Express request object.
656
- * @param {object} res - The Express response object (to set the cookie).
657
- * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number}>}
436
+ * @param {object} context - The request context.
437
+ * @returns {Promise<{deviceId: string, deviceData: object, consistencyScore: number, newCookie: object|null}>}
658
438
  */
659
- async function resolveRequestIdentity(req, res) {
660
- const existingDeviceId = req.cookies?.device_id;
661
- const currentDeviceHash = getDeviceHash(req);
439
+ async function resolveRequestIdentity(context) {
440
+ const existingDeviceId = context.cookies?.device_id;
441
+ const currentDeviceHash = getDeviceHash(context);
662
442
  let deviceId = existingDeviceId;
663
443
  let consistencyScore = 1.0; // 1.0 = perfectly consistent
664
444
  let deviceData = null;
445
+ let newCookie = null;
665
446
 
666
447
  if (deviceId) {
667
448
  deviceData = await store.get(`device:${deviceId}`);
@@ -680,13 +461,14 @@ async function resolveRequestIdentity(req, res) {
680
461
  // Case 2: New user or lost/invalid cookie.
681
462
  deviceId = crypto.randomUUID(); // Generate a new "passport".
682
463
 
683
- // Set the cookie securely.
684
- res.cookie("device_id", deviceId, {
685
- httpOnly: true,
686
- secure: process.env.NODE_ENV === "production",
687
- sameSite: "strict",
688
- maxAge: 31536000000, // 1 year
689
- });
464
+ // Return the intention to set a cookie.
465
+ newCookie = {
466
+ name: "device_id",
467
+ value: deviceId,
468
+ options: {
469
+ httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: 31536000000, // 1 year
470
+ }
471
+ };
690
472
 
691
473
  // Initialize tracking for this new device.
692
474
  deviceData = {
@@ -700,24 +482,24 @@ async function resolveRequestIdentity(req, res) {
700
482
  // The write will happen in getSuspicionVector after all modifications.
701
483
  }
702
484
 
703
- return { deviceId, deviceData, consistencyScore };
485
+ return { deviceId, deviceData, consistencyScore, newCookie };
704
486
  }
705
487
 
706
488
  /*
707
489
  * Calcule les indicateurs de suspicion liés au comportement de l'appareil (historique, rotation).
708
- * @param {object} req - The Express request object.
490
+ * @param {object} context - The request context.
709
491
  * @param {object} deviceData - The device's activity data.
710
492
  * @returns {Promise<{historyScore: number, rotationScore: number}>}
711
493
  */
712
- async function getBehavioralIndicators(req, deviceData) {
494
+ async function getBehavioralIndicators(context, deviceData) {
713
495
  const now = Date.now();
714
- const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
496
+ const clientIp = context.clientIp;
715
497
 
716
498
  // Get the IP type to modulate the score
717
499
  const ipProfile = (await store.get(`ip:${clientIp}`)) || { type: "residential" };
718
500
  const isSharedIp = ipProfile.type === "shared";
719
501
 
720
- const currentFpHash = getDeviceHash(req); // Use the device hash
502
+ const currentFpHash = getDeviceHash(context); // Use the device hash
721
503
 
722
504
  // --- Behavior analysis (Change frequency) ---
723
505
  if (deviceData.lastFpHash && currentFpHash !== deviceData.lastFpHash) {
@@ -727,7 +509,7 @@ async function getBehavioralIndicators(req, deviceData) {
727
509
  deviceData.rapidChangeCount = Math.min(
728
510
  deviceData.rapidChangeCount + 1,
729
511
  MAX_RAPID_CHANGES_PER_DEVICE * 2, // Increases quickly
730
- );
512
+ );
731
513
  } else {
732
514
  deviceData.rapidChangeCount = Math.max(0, deviceData.rapidChangeCount - 1); // Decreases slowly
733
515
  }
@@ -762,13 +544,20 @@ async function getBehavioralIndicators(req, deviceData) {
762
544
 
763
545
  /**
764
546
  * Returns a vector of raw (unweighted) suspicion scores.
765
- * @param {object} req - The Express request object.
547
+ * @param {object} context - The request context object.
766
548
  * @returns {Promise<{historyScore: number, rotationScore: number, headerAnomalyScore: number, inconsistencyScore: number}>}
767
549
  */
768
- export const getSuspicionVector = async (req, res) => {
769
- const { deviceId, deviceData, consistencyScore } = await resolveRequestIdentity(req, res);
550
+ export const getSuspicionVector = async (context) => {
551
+ const { deviceId, deviceData, consistencyScore, newCookie } = await resolveRequestIdentity(context);
770
552
 
771
- const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
553
+ const clientIp = context.clientIp;
554
+
555
+ // If a new cookie needs to be set, attach it to the request object
556
+ // so the middleware can handle it. This is a temporary state holder.
557
+ if (newCookie) {
558
+ context._newCookies = context._newCookies || [];
559
+ context._newCookies.push(newCookie);
560
+ }
772
561
  await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
773
562
 
774
563
  // Periodically clean up device data
@@ -778,13 +567,16 @@ export const getSuspicionVector = async (req, res) => {
778
567
  }
779
568
  deviceData.lastUpdate = Date.now();
780
569
 
781
- const behavioral = await getBehavioralIndicators(req, deviceData);
782
- const anomalies = getHeaderAnomalies(req, consistencyScore);
570
+ const behavioral = await getBehavioralIndicators(context, deviceData);
571
+ const { headerAnomalyScore } = getHeaderAnomalies(context);
572
+ // Calculate the inconsistency score here, separately.
573
+ const inconsistencyScore = Math.min(100, Math.max(0, (1 - consistencyScore) * 200));
574
+
783
575
 
784
576
  // Save the updated device state to the store
785
577
  await store.set(`device:${deviceId}`, deviceData);
786
578
 
787
- return { ...behavioral, ...anomalies };
579
+ return { ...behavioral, headerAnomalyScore, inconsistencyScore };
788
580
  };
789
581
 
790
582
  // A residential user can change networks (home, 4G, public wifi).
@@ -804,61 +596,29 @@ const MAX_RAPID_CHANGES_PER_DEVICE = 3; // Number of rapid fingerprint changes a
804
596
  * and IP, making spoofing more complex (requires changing the entire stack).
805
597
  */
806
598
  export const identifyRequest = async (req, res) => {
807
- const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
808
- const deviceId = req.cookies?.device_id;
809
-
810
- // --- Update IP reputation ---
811
- const ipProfile = (await store.get(`ip:${clientIp}`)) || {
812
- type: "residential",
813
- deviceIds: new Set(),
814
- statelessCount: 0,
815
- lastSeen: 0,
599
+ // This function now acts as a lightweight wrapper around the engine's identifyRequest method.
600
+ // It requires a default configuration to work.
601
+ const defaultConfig = {
602
+ weights: { historyScore: 0.3, rotationScore: 0.5, headerAnomalyScore: 0.1, inconsistencyScore: 0.8 },
603
+ thresholds: { low: 20, medium: 40, high: 75 }
816
604
  };
817
- ipProfile.lastSeen = Date.now();
818
- if (deviceId) {
819
- ipProfile.deviceIds.add(deviceId);
820
- } else {
821
- // Improved anti-"Amnesiac Bot" logic
822
- ipProfile.statelessCount++;
823
- }
605
+ const engine = new FingerprintEngine(defaultConfig);
824
606
 
825
- // If an IP sees too many different devices, classify it as "shared".
826
- if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
827
- ipProfile.type = "shared";
828
- }
607
+ const requestContext = {
608
+ clientIp: req.ip || req.socket?.remoteAddress || "unknown",
609
+ cookies: req.cookies,
610
+ headers: req.headers,
611
+ rawHeaders: req.rawHeaders,
612
+ httpVersion: req.httpVersion,
613
+ };
829
614
 
830
- // If a residential IP makes too many requests without a cookie, it's a bot.
831
- // For a shared IP, we are more tolerant because new users are constantly arriving.
832
- const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
833
- if (ipProfile.statelessCount > statelessLimit) {
834
- return `suspicious_high:${clientIp}`;
835
- }
836
- await store.set(`ip:${clientIp}`, ipProfile);
837
-
838
- // For compatibility with the rate-limiter, calculate a simple score.
839
- // The PoW will use the more complex weighted system.
840
- const vector = await getSuspicionVector(req, res);
841
- const score =
842
- vector.historyScore * 0.3 +
843
- vector.rotationScore * 0.5 +
844
- vector.headerAnomalyScore * 0.1 +
845
- vector.inconsistencyScore * 0.8; // Inconsistency is a very strong signal
846
-
847
- // Return a string for compatibility with rate limiters,
848
- // but based on suspicion thresholds.
849
- // NOTE: These thresholds are fixed here, but the PoW will use dynamic thresholds.
850
- if (score >= 75) {
851
- return `suspicious_high:${clientIp}`;
852
- }
853
- if (score >= 40) {
854
- return `suspicious_medium:${clientIp}`;
615
+ const key = await engine.identifyRequest(requestContext);
616
+
617
+ if (requestContext._newCookies && res) {
618
+ requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
855
619
  }
856
620
 
857
- // For normal requests, return a hash of the fingerprint for rate limiting.
858
- // Use the device hash so the rate-limit follows the device, not the IP.
859
- const deviceIdForIp = await store.get(`ip-device:${clientIp}`);
860
- const finalDeviceId = deviceId || deviceIdForIp || clientIp;
861
- return `device:${finalDeviceId}`;
621
+ return key;
862
622
  };
863
623
  // --- NOUVEAU CHALLENGE CPU "ANALOGIQUE" ---
864
624
 
@@ -952,7 +712,7 @@ function generateCpuTargetChallengePage(challengeDetails, clientIp) {
952
712
  * @param {string} clientIp - The client's IP address.
953
713
  * @returns {string} HTML content.
954
714
  */
955
- function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp) {
715
+ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty, clientIp, clientSecret) {
956
716
  const { nonce, target, path } = cpuChallengeDetails;
957
717
  return `
958
718
  <html><head><title>Advanced Security Check</title></head>
@@ -964,13 +724,14 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
964
724
  async function solve() {
965
725
  const nonce = "${nonce}";
966
726
  const path = "${path}";
727
+ const clientSecret = "${clientSecret}"; // Secret is now available to the client
967
728
 
968
729
  // --- CPU Challenge ---
969
730
  document.getElementById('loader').innerText = '⚙️ Performing CPU security calculation...';
970
731
  const cpuTarget = BigInt("0x${target}");
971
732
  let cpuSolution = 0;
972
733
  while (true) {
973
- const msg = "${clientIp}:${nonce}:" + cpuSolution;
734
+ const msg = "${clientIp}:${nonce}:" + cpuSolution + ":" + clientSecret;
974
735
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
975
736
  const hashHex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
976
737
  if (BigInt('0x' + hashHex) < cpuTarget) break;
@@ -983,14 +744,16 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
983
744
  await new Promise(r => setTimeout(r, 10)); // Yield to update UI
984
745
 
985
746
  let memSolution = 0;
747
+ const iterations = size / 16;
986
748
  try {
987
749
  const size = ${memoryDifficulty} * 1024 * 1024;
988
750
  const buffer = new Uint32Array(size / 4);
989
- let h = new TextEncoder().encode(nonce).reduce((acc, v) => acc + v, 0);
751
+ const seed = nonce + ":" + clientSecret;
752
+ let h = new TextEncoder().encode(seed).reduce((acc, v) => acc + v, 0);
990
753
  for (let i = 0; i < buffer.length; i++) {
991
- buffer[i] = (h = Math.imul(h ^ i, 1597334677));
754
+ buffer[i] = h = Math.imul(h ^ i, 1597334677);
992
755
  }
993
- for(let i = 0; i < (size / 16); i++) {
756
+ for(let i = 0; i < iterations; i++) {
994
757
  const addr = buffer[i % buffer.length] % buffer.length;
995
758
  memSolution ^= buffer[addr];
996
759
  }
@@ -1013,11 +776,13 @@ export function verifyCpuTargetPoWAndGenerateTicket(
1013
776
  nonce,
1014
777
  solution,
1015
778
  suspicionFactor,
779
+ clientSecret, // Le secret est maintenant requis
1016
780
  ) {
1017
781
  const target = calculateTarget(suspicionFactor);
782
+ const message = clientSecret ? `${clientIp}:${nonce}:${solution}:${clientSecret}` : `${clientIp}:${nonce}:${solution}`;
1018
783
  const hash = crypto
1019
784
  .createHash("sha256")
1020
- .update(`${clientIp}:${nonce}:${solution}`)
785
+ .update(message)
1021
786
  .digest("hex");
1022
787
  const hashAsInt = BigInt("0x" + hash);
1023
788
 
@@ -1026,7 +791,7 @@ export function verifyCpuTargetPoWAndGenerateTicket(
1026
791
  // The proof is valid, generate the ticket
1027
792
  const expiry = Date.now() + 3600000; // 1 heure
1028
793
  const signature = crypto
1029
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
794
+ .createHmac("sha256", getPowSecret())
1030
795
  .update(`${clientIp}:${expiry}`)
1031
796
  .digest("hex");
1032
797
  return `${expiry}:${signature}`;
@@ -1035,9 +800,11 @@ export function verifyCpuTargetPoWAndGenerateTicket(
1035
800
  return null;
1036
801
  }
1037
802
 
1038
- const staticExtensions =
1039
- /\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map)$/i;
1040
- const isStaticResource = (req) => staticExtensions.test(req.path);
803
+ const staticExtensions = new RegExp(
804
+ "\\.(js|css|png|jpg|jpeg|gif|svg|mp3|webp|ico|woff|woff2|ttf|otf|map)$",
805
+ "i",
806
+ );
807
+ const isStaticResource = (path) => staticExtensions.test(path);
1041
808
 
1042
809
  // --- Middleware Proof-of-Work (Le péage) ---
1043
810
  class FingerprintEngine {
@@ -1052,12 +819,11 @@ class FingerprintEngine {
1052
819
  const { weights, thresholds, logger } = this.securityConfig;
1053
820
 
1054
821
  if (isStatic) {
1055
- return { action: 'next' };
822
+ return { action: 'next', score: 0, vector: {} };
1056
823
  }
1057
824
 
1058
- // We need to pass `req` and `res` to getSuspicionVector for cookie handling.
1059
- // This is a remaining coupling point that could be refactored further.
1060
- const suspicionVector = await __internal.getSuspicionVector(requestContext.rawReq, requestContext.rawRes);
825
+ // The engine now works with the context directly, no more rawReq dependency here.
826
+ const suspicionVector = await __internal.getSuspicionVector(requestContext);
1061
827
 
1062
828
  const finalScore =
1063
829
  suspicionVector.historyScore * (weights.historyScore || 0) +
@@ -1076,18 +842,15 @@ class FingerprintEngine {
1076
842
  (finalScore - thresholds.low) / (thresholds.high - thresholds.low),
1077
843
  )
1078
844
  : 0;
1079
- const powCookie = cookies?.pow_clearance;
845
+ const powCookie = cookies?.pow_clearance;
1080
846
  const { pow_type, pow_nonce, pow_solution, pow_solution_cpu, pow_solution_mem } = query;
1081
847
 
1082
- // Basic log for each non-static request
1083
- if (logger && !isSuspicious) {
1084
- logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
1085
- }
1086
-
1087
848
  if (isSuspicious && !isTicketValid(clientIp, powCookie)) {
1088
849
  // --- CHALLENGE SOLUTION HANDLING ---
1089
850
  if (pow_nonce && (pow_solution || (pow_solution_cpu && pow_solution_mem))) {
1090
851
  let isValid = false,
852
+ // Retrieve the client-side secret associated with this nonce
853
+ clientSecret = await store.get(`secret:${pow_nonce}`),
1091
854
  ticket = null;
1092
855
  if (pow_type === "cpu_target") {
1093
856
  // Verify the new type
@@ -1095,25 +858,20 @@ class FingerprintEngine {
1095
858
  clientIp,
1096
859
  pow_nonce,
1097
860
  pow_solution,
1098
- suspicionFactor, // Pass the analog factor directly
861
+ suspicionFactor, // Pass the analog factor
862
+ clientSecret,
1099
863
  );
1100
- isValid = ticket !== null;
1101
- } else if (pow_type === "mem") {
1102
- const minDifficulty = 16; // 16Mo
1103
- const maxDifficulty = 48; // 48Mo
1104
- const difficulty =
1105
- minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
1106
- isValid = verifyMemoryPoW(pow_nonce, pow_solution, difficulty);
1107
- } else if (pow_type === "cpu_mem") {
864
+ isValid = ticket !== null; } else if (pow_type === "cpu_mem") {
1108
865
  // Verify combined challenge
1109
866
  const cpuTicket = verifyCpuTargetPoWAndGenerateTicket(
1110
- clientIp, pow_nonce, pow_solution_cpu, suspicionFactor
867
+ clientIp, pow_nonce, pow_solution_cpu, suspicionFactor, clientSecret
1111
868
  );
1112
-
1113
869
  const minDifficulty = 16; // 16Mo
1114
870
  const maxDifficulty = 48; // 48Mo
1115
- const memDifficulty = minDifficulty + suspicionFactor * (maxDifficulty - minDifficulty);
1116
- const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty);
871
+ const memActivationFactor = Math.max(0, (suspicionFactor - 0.25) / 0.75);
872
+ const memDifficulty = Math.round(minDifficulty + memActivationFactor * (maxDifficulty - minDifficulty));
873
+
874
+ const isMemValid = verifyMemoryPoW(pow_nonce, pow_solution_mem, memDifficulty, clientSecret);
1117
875
 
1118
876
  isValid = cpuTicket !== null && isMemValid;
1119
877
  if (isValid) ticket = cpuTicket; // Reuse the ticket generated by the CPU verification
@@ -1123,11 +881,16 @@ class FingerprintEngine {
1123
881
  }
1124
882
 
1125
883
  if (isValid) {
884
+ // The secret has been used, delete it to prevent replay.
885
+ if (clientSecret) {
886
+ await store.delete(`secret:${pow_nonce}`);
887
+ }
888
+
1126
889
  if (!ticket) {
1127
890
  // If the ticket has not already been generated (CPU case)
1128
891
  const expiry = Date.now() + 3600000; // 1 heure
1129
892
  const signature = crypto
1130
- .createHmac("sha256", POW_SECRET || "fallback-dev-secret-32-chars-minimum")
893
+ .createHmac("sha256", getPowSecret())
1131
894
  .update(`${clientIp}:${expiry}`)
1132
895
  .digest("hex");
1133
896
  ticket = `${expiry}:${signature}`;
@@ -1140,6 +903,8 @@ class FingerprintEngine {
1140
903
  return {
1141
904
  action: 'redirect',
1142
905
  path: path,
906
+ score: finalScore,
907
+ vector: suspicionVector,
1143
908
  cookie: {
1144
909
  name: 'pow_clearance',
1145
910
  value: ticket,
@@ -1155,6 +920,10 @@ class FingerprintEngine {
1155
920
 
1156
921
  // --- SELECTION AND SENDING OF THE APPROPRIATE CHALLENGE ---
1157
922
  const nonce = crypto.randomBytes(16).toString("hex");
923
+ const clientSecret = crypto.randomBytes(16).toString("hex");
924
+
925
+ // Store the secret with a short TTL (e.g., 5 minutes)
926
+ await store.set(`secret:${nonce}`, clientSecret, 300);
1158
927
 
1159
928
  if (logger) {
1160
929
  logger({ type: 'challenge_issued', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
@@ -1165,23 +934,7 @@ class FingerprintEngine {
1165
934
  // ... logic for TSP/Captcha challenge
1166
935
  }
1167
936
 
1168
- // LEVEL 2: Memory-Intensive PoW
1169
- if (isSuspiciousMedium) {
1170
- // Utilisons notre nouveau challenge combiné !
1171
- const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
1172
-
1173
- const minMemDifficulty = 16; // 16Mo
1174
- const maxMemDifficulty = 48; // 48Mo
1175
- const memDifficulty = minMemDifficulty + suspicionFactor * (maxMemDifficulty - minMemDifficulty);
1176
-
1177
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
1178
- return {
1179
- action: 'challenge',
1180
- status: 429, body: page
1181
- };
1182
- }
1183
-
1184
- // NOUVELLE LOGIQUE UNIFIÉE POUR TOUS LES NIVEAUX DE SUSPICION (low et medium)
937
+ // UNIFIED CHALLENGE LOGIC for all suspicion levels (low, medium, high)
1185
938
  if (isSuspicious) { // Couvre à la fois low et medium
1186
939
  const cpuChallengeDetails = generateCpuTargetChallenge(clientIp, nonce, suspicionFactor, path);
1187
940
 
@@ -1191,15 +944,70 @@ class FingerprintEngine {
1191
944
 
1192
945
  const minMemDifficulty = 0; // Peut être 0 Mo !
1193
946
  const maxMemDifficulty = 48; // 48Mo pour les plus suspects
1194
- const memDifficulty = minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty);
947
+ const memDifficulty = Math.round(minMemDifficulty + memActivationFactor * (maxMemDifficulty - minMemDifficulty));
1195
948
 
1196
- // On utilise toujours la page combinée, même si la difficulté mémoire est 0 (le calcul sera quasi instantané).
1197
- const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp);
1198
- return { action: 'challenge', status: 429, body: page };
949
+ // Always use the combined page, even if memory difficulty is 0 (it will be almost instant).
950
+ const page = generateCombinedPoWChallengePage(cpuChallengeDetails, memDifficulty, clientIp, clientSecret);
951
+ return { action: 'challenge', score: finalScore, vector: suspicionVector, status: 429, body: page };
1199
952
  }
1200
953
  }
1201
954
 
1202
- return { action: 'next' };
955
+ // Basic log for each non-static request that passed without a challenge
956
+ if (logger) {
957
+ logger({ type: 'request_passed', deviceId: cookies?.device_id, score: finalScore, timestamp: Date.now() });
958
+ }
959
+
960
+ return { action: 'next', score: finalScore, vector: suspicionVector };
961
+ }
962
+
963
+ /**
964
+ * Identifies a request in a granular way for non-Express environments.
965
+ * @param {object} requestContext - The request context object.
966
+ * @returns {Promise<string>} An identification string (e.g., "device:<id>", "suspicious_high:<ip>").
967
+ */
968
+ async identifyRequest(requestContext) {
969
+ const { clientIp, cookies, rawReq, rawRes } = requestContext;
970
+
971
+ // --- Update IP reputation ---
972
+ const ipProfile = (await store.get(`ip:${clientIp}`)) || {
973
+ type: "residential",
974
+ deviceIds: new Set(),
975
+ statelessCount: 0,
976
+ lastSeen: 0,
977
+ };
978
+ ipProfile.lastSeen = Date.now();
979
+ if (cookies?.device_id) {
980
+ ipProfile.deviceIds.add(cookies.device_id);
981
+ } else {
982
+ ipProfile.statelessCount++;
983
+ }
984
+
985
+ if (ipProfile.deviceIds.size > SHARED_IP_DEVICE_THRESHOLD) {
986
+ ipProfile.type = "shared";
987
+ }
988
+
989
+ const statelessLimit = ipProfile.type === "shared" ? 50 : 10;
990
+ if (ipProfile.statelessCount > statelessLimit) {
991
+ return `suspicious_high:${clientIp}`;
992
+ }
993
+ await store.set(`ip:${clientIp}`, ipProfile);
994
+
995
+ const vector = await __internal.getSuspicionVector(requestContext);
996
+ const score =
997
+ vector.historyScore * (this.securityConfig.weights.historyScore || 0.3) +
998
+ vector.rotationScore * (this.securityConfig.weights.rotationScore || 0.5) +
999
+ vector.headerAnomalyScore * (this.securityConfig.weights.headerAnomalyScore || 0.1) +
1000
+ vector.inconsistencyScore * (this.securityConfig.weights.inconsistencyScore || 0.8);
1001
+
1002
+ if (score >= this.securityConfig.thresholds.high) return `suspicious_high:${clientIp}`;
1003
+ if (score >= this.securityConfig.thresholds.low) return `suspicious_medium:${clientIp}`; // Use medium for any suspicion
1004
+ if (score >= this.securityConfig.thresholds.medium) return `suspicious_medium:${clientIp}`;
1005
+
1006
+ // If a new device_id was created, it's in the context.
1007
+ const newDeviceId = requestContext._newCookies?.find(c => c.name === 'device_id')?.value;
1008
+ const finalDeviceId = cookies?.device_id || newDeviceId || clientIp;
1009
+
1010
+ return `device:${finalDeviceId}`;
1203
1011
  }
1204
1012
  }
1205
1013
 
@@ -1207,6 +1015,13 @@ class FingerprintEngine {
1207
1015
  export const powMiddleware = (securityConfig) => {
1208
1016
  const engine = new FingerprintEngine(securityConfig);
1209
1017
 
1018
+ if (securityConfig.autotuning) {
1019
+ startThresholdAutoTuning({
1020
+ securityConfig: securityConfig,
1021
+ ...securityConfig.autotuning,
1022
+ });
1023
+ }
1024
+
1210
1025
  return async (req, res, next) => {
1211
1026
  const requestContext = {
1212
1027
  clientIp: req.ip || req.socket?.remoteAddress || "unknown",
@@ -1214,15 +1029,29 @@ export const powMiddleware = (securityConfig) => {
1214
1029
  cookies: req.cookies,
1215
1030
  query: req.query,
1216
1031
  headers: req.headers,
1217
- isStatic: isStaticResource(req),
1218
- // Pass raw req/res for now to handle cookie setting in resolveRequestIdentity
1219
- rawReq: req,
1220
- rawRes: res,
1032
+ isStatic: isStaticResource(req.path),
1033
+ // Add the newly required properties for full decoupling
1034
+ rawHeaders: req.rawHeaders,
1035
+ httpVersion: req.httpVersion,
1221
1036
  };
1222
1037
 
1223
1038
  const decision = await engine.processRequest(requestContext);
1224
1039
 
1040
+ // Attach the fingerprinting result to the request object for downstream middlewares.
1041
+ req.fingerprint = {
1042
+ score: decision.score,
1043
+ vector: decision.vector,
1044
+ };
1045
+
1046
+ // After getSuspicionVector runs, it might have attached cookies to be set.
1047
+ if (requestContext._newCookies) {
1048
+ requestContext._newCookies.forEach(c => res.cookie(c.name, c.value, c.options));
1049
+ }
1050
+
1225
1051
  switch (decision.action) {
1052
+ case 'block':
1053
+ return res.status(decision.status).send(decision.body);
1054
+
1226
1055
  case 'challenge':
1227
1056
  return res.status(decision.status).send(decision.body);
1228
1057
 
@@ -1245,7 +1074,10 @@ export const powMiddleware = (securityConfig) => {
1245
1074
  * This is a common pattern to allow mocking of ES module functions.
1246
1075
  */
1247
1076
  export const __internal = {
1077
+ getDeviceHash,
1248
1078
  getSuspicionVector,
1079
+ cyrb53, // Export for testing
1080
+ FingerprintBuilder, // Export for testing
1249
1081
  calculateTarget,
1250
1082
  FingerprintEngine, // Expose for advanced testing
1251
1083
  };
@@ -1284,7 +1116,7 @@ function runThresholdOptimization(securityConfig, trafficData, minDataPoints) {
1284
1116
  const fitnessFunction = (solution) => {
1285
1117
  const [low, medium, high] = solution;
1286
1118
  // Constraints: thresholds must be ordered and within a reasonable range.
1287
- if (low >= medium || medium >= high || low < 10 || high > 90) return Infinity;
1119
+ if (low >= medium || medium >= high || low <= 10 || high >= 90) return Infinity;
1288
1120
 
1289
1121
  let falsePositives = 0; // Humans challenged unnecessarily.
1290
1122
  let falseNegatives = 0; // Undetected bots.
package/library.js CHANGED
@@ -1443,4 +1443,4 @@ Optimization.Operators.solveFraudDetection = (context, options = {}) => {
1443
1443
  );
1444
1444
  };
1445
1445
 
1446
- export { Dichotomy, Optimization };
1446
+ export { Optimization };
package/package.json CHANGED
@@ -1,50 +1,47 @@
1
- {
2
- "name": "@anonympins/fingerprint",
3
- "version": "0.0.2",
4
- "description": "An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.",
5
- "main": "fingerprint.js",
6
- "type": "module",
7
- "engines": {
8
- "node": ">=18.0.0"
9
- },
10
- "scripts": {
11
- "test": "vitest run"
12
- },
13
- "files": [
14
- "fingerprint.js",
15
- "library.js",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/anonympins/fingerprint.git"
22
- },
23
- "keywords": [
24
- "fingerprint",
25
- "bot",
26
- "anti-bot",
27
- "security",
28
- "express",
29
- "middleware",
30
- "proof-of-work",
31
- "pow",
32
- "rate-limiting",
33
- "mitigation",
34
- "captcha"
35
- ],
36
- "author": "anonympins",
37
- "license": "MIT",
38
- "bugs": {
39
- "url": "https://github.com/anonympins/fingerprint/issues"
40
- },
41
- "homepage": "https://github.com/anonympins/fingerprint#readme",
42
- "devDependencies": {
43
- "cookie-parser": "^1.4.6",
44
- "express": "^4.18.2",
45
- "vitest": "^1.4.0"
46
- },
47
- "dependencies": {
48
-
49
- }
50
- }
1
+ {
2
+ "name": "@anonympins/fingerprint",
3
+ "version": "0.0.4",
4
+ "description": "An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.",
5
+ "main": "fingerprint.js",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20.0.0"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest run"
12
+ },
13
+ "files": [
14
+ "fingerprint.js",
15
+ "library.js",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/anonympins/fingerprint.git"
22
+ },
23
+ "keywords": [
24
+ "fingerprint",
25
+ "bot",
26
+ "anti-bot",
27
+ "security",
28
+ "express",
29
+ "middleware",
30
+ "proof-of-work",
31
+ "pow",
32
+ "rate-limiting",
33
+ "mitigation",
34
+ "captcha"
35
+ ],
36
+ "author": "anonympins",
37
+ "license": "MIT",
38
+ "bugs": {
39
+ "url": "https://github.com/anonympins/fingerprint/issues"
40
+ },
41
+ "homepage": "https://github.com/anonympins/fingerprint#readme",
42
+ "devDependencies": {
43
+ "cookie-parser": "^1.4.6",
44
+ "express": "^4.18.2",
45
+ "vitest": "^4.1.11"
46
+ }
47
+ }