@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/README.md +80 -43
- package/fingerprint.builder.js +104 -0
- package/fingerprint.client.js +51 -2
- package/fingerprint.js +311 -177
- package/library.js +1577 -1446
- package/mongodb-store.js +53 -0
- package/package.json +23 -9
- package/redis-store.js +43 -0
- package/sql-store.js +78 -0
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ The process unfolds in three steps:
|
|
|
25
25
|
* **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.
|
|
26
26
|
* **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.
|
|
27
27
|
|
|
28
|
-
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
|
+
Once the challenge is solved, a clearance "ticket" is issued via a secure cookie, exempting the user from new challenges for a set period. For API clients, the challenge is delivered as a `429` JSON response, and the client is expected to solve it and retry the request.
|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
@@ -81,21 +81,33 @@ const securityConfig = {
|
|
|
81
81
|
headerAnomalyScore: 0.1, // Penalizes abnormal headers (missing UA, etc.)
|
|
82
82
|
requestPatternScore: 0.6,// Penalizes bot-like request sequences (scraping, etc.)
|
|
83
83
|
inconsistencyScore: 0.8, // Strongly penalizes inconsistency between the current and initial fingerprint (stolen cookie)
|
|
84
|
+
behaviorScore: 0.7, // Penalizes non-human interactions (no mouse/keyboard activity)
|
|
84
85
|
honeypotScore: 1.0 // Strongly penalizes bots filling hidden form fields
|
|
85
86
|
},
|
|
87
|
+
// A new, non-suspicious device will always have its score adjusted to a minimum of 1, ensuring it receives a minimal, almost imperceptible challenge on its first visit.
|
|
86
88
|
thresholds: {
|
|
87
89
|
low: 20, // Score from which a CPU challenge is issued
|
|
88
90
|
medium: 45, // Score for a more difficult combined CPU/Memory challenge
|
|
89
91
|
high: 75, // Score for a very difficult challenge
|
|
90
92
|
block: 95, // Score above which the request is blocked outright (HTTP 403)
|
|
91
|
-
isStaticResource: (req) => req.path.startsWith('/static/') // Optional: Custom function to identify static resources
|
|
93
|
+
isStaticResource: (req) => req.path.startsWith('/static/'), // Optional: Custom function to identify static resources
|
|
94
|
+
isApiRequest: (req) => req.path.startsWith('/api/') || req.headers.accept?.includes('application/json') // Optional: Custom function to identify API requests
|
|
92
95
|
},
|
|
96
|
+
// (Optional) Configure the duration (in milliseconds) for various temporary data.
|
|
97
|
+
ticketMaxAge: 3600000, // 1 hour. Duration for which a solved challenge ticket is valid.
|
|
98
|
+
challengeTtl: 300000, // 5 minutes. Time during which a challenge nonce is valid.
|
|
99
|
+
deviceIdCookieMaxAge: undefined, // By default, it's a session cookie. Set a value in ms for a persistent cookie.
|
|
100
|
+
|
|
93
101
|
patterns: { // (Optional) Initial values for request pattern detection, optimized by auto-tuner if enabled.
|
|
94
|
-
velocityThreshold: 200,
|
|
95
|
-
burstThreshold: 500,
|
|
96
|
-
scrapeThreshold: 1000,
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
velocityThreshold: 200, // ms between requests to be considered "fast"
|
|
103
|
+
burstThreshold: 500, // ms for identical requests to be a "burst"
|
|
104
|
+
scrapeThreshold: 1000, // ms for sequential requests to be "scraping"
|
|
105
|
+
scrapeBurstWeight: 40, // Additional weight for repeated scraping patterns
|
|
106
|
+
sequenceLength: 3, // Length of a request sequence to detect (e.g., A->B->C)
|
|
107
|
+
sequenceWeight: 60, // Penalty for repeating a sequence
|
|
108
|
+
historySize: 10, // Number of requests to keep for pattern analysis
|
|
109
|
+
decayFactor: 0.9, // How quickly the pattern score decays over time
|
|
110
|
+
inactivityReset: 30000, // ms of inactivity after which the pattern score is reset
|
|
99
111
|
},
|
|
100
112
|
honeypot: {
|
|
101
113
|
// List of field names that are traps for bots.
|
|
@@ -140,10 +152,10 @@ const securityConfig = {
|
|
|
140
152
|
// Useful for internal tools, trusted partners, or monitoring services.
|
|
141
153
|
// This check is performed first for maximum efficiency.
|
|
142
154
|
{ type: 'allowlist', entries: [
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
'192.168.1.100', // A specific internal IP
|
|
156
|
+
'203.0.113.0/24', // A partner's network range
|
|
157
|
+
'2001:db8::/32' // An IPv6 range
|
|
158
|
+
]},
|
|
147
159
|
// Option 2: DNS-verified bots (e.g., search engine crawlers).
|
|
148
160
|
// This uses a secure DNS lookup (reverse then forward) to verify the bot's identity.
|
|
149
161
|
// The result is cached per IP to avoid repeated DNS lookups.
|
|
@@ -198,17 +210,41 @@ The main Express middleware. It orchestrates identification, suspicion calculati
|
|
|
198
210
|
|
|
199
211
|
#### `configureStore(store)`
|
|
200
212
|
Allows replacing the in-memory store with an external datastore (like Redis) for persistence and scaling.
|
|
213
|
+
The library provides ready-to-use adapters for popular datastores like Redis and MongoDB, which automatically handle the Time-To-Live (TTL) required for temporary data like challenge secrets.
|
|
201
214
|
|
|
202
|
-
|
|
215
|
+
**Redis Example:**
|
|
203
216
|
|
|
204
217
|
```javascript
|
|
205
218
|
import { configureStore } from './fingerprint.js';
|
|
206
|
-
import { createRedisStore } from './redis-store.js';
|
|
219
|
+
import { createRedisStore } from './redis-store.js';
|
|
220
|
+
import Redis from 'ioredis';
|
|
207
221
|
|
|
208
|
-
const
|
|
222
|
+
const redisClient = new Redis(process.env.REDIS_URL);
|
|
223
|
+
const redisStore = createRedisStore(redisClient);
|
|
209
224
|
configureStore(redisStore);
|
|
210
225
|
```
|
|
211
226
|
|
|
227
|
+
**MongoDB Example:**
|
|
228
|
+
|
|
229
|
+
```javascript
|
|
230
|
+
import { configureStore } from './fingerprint.js';
|
|
231
|
+
import { createMongoDbStore } from './mongodb-store.js';
|
|
232
|
+
import { MongoClient } from 'mongodb';
|
|
233
|
+
|
|
234
|
+
const mongoClient = new MongoClient(process.env.MONGODB_URL);
|
|
235
|
+
|
|
236
|
+
// It's recommended to connect before your application starts listening.
|
|
237
|
+
await mongoClient.connect();
|
|
238
|
+
|
|
239
|
+
const mongoStore = createMongoDbStore(mongoClient.db('your-db-name'), 'sessions'); // 'sessions' is the collection name
|
|
240
|
+
configureStore(mongoStore);
|
|
241
|
+
|
|
242
|
+
// IMPORTANT: For automatic expiration of challenges and other temporary data to work,
|
|
243
|
+
// you must create a TTL index on the `expiresAt` field in your MongoDB collection.
|
|
244
|
+
// Run this command in the mongo shell:
|
|
245
|
+
// db.sessions.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 })
|
|
246
|
+
```
|
|
247
|
+
|
|
212
248
|
#### `identifyRequest(req, res)`
|
|
213
249
|
An asynchronous function that returns an identification string for a given request, based on its suspicion level (`device:<id>`, `suspicious_medium:<ip>`, etc.). Useful for integration with a custom rate-limiter.
|
|
214
250
|
|
|
@@ -300,34 +336,35 @@ To simplify setup, all client-side features can be enabled and configured throug
|
|
|
300
336
|
|
|
301
337
|
```javascript
|
|
302
338
|
import { initializeClient } from './path/to/fingerprint.client.js';
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Initializes all client-side protections.
|
|
342
|
+
* This is the recommended way to set up the client-side library.
|
|
343
|
+
*/
|
|
307
344
|
initializeClient({
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
345
|
+
// (Optional, default: true) Enable mouse movement tracking to detect non-human patterns.
|
|
346
|
+
// Set to `false` to disable.
|
|
347
|
+
mouse: true,
|
|
348
|
+
|
|
349
|
+
// (Optional, default: true) Enable keystroke dynamics tracking (timing between key presses).
|
|
350
|
+
// Set to `false` to disable.
|
|
351
|
+
keystrokes: true,
|
|
352
|
+
|
|
353
|
+
// (Optional) An array of `name` attributes for hidden form fields that act as bot traps.
|
|
354
|
+
honeypots: ['email_confirm', 'user_nickname', 'website_url'],
|
|
355
|
+
|
|
356
|
+
// (Optional) Enables automatic protection for `fetch` requests.
|
|
357
|
+
// If the `fetch` object is present, the protection is active.
|
|
315
358
|
fetch: {
|
|
316
|
-
|
|
359
|
+
// (Optional) An array of domains to protect. If empty or not provided, it protects same-origin requests by default.
|
|
360
|
+
targetDomains: ['api.yourdomain.com', 'auth.yourdomain.com'],
|
|
361
|
+
|
|
362
|
+
// (Optional, default: true) If enabled, the client will automatically intercept 429 challenge responses,
|
|
363
|
+
// solve the PoW in the background, and retry the original request with the solution.
|
|
364
|
+
// This makes the protection seamless for API clients that use this library.
|
|
365
|
+
handleChallenges: true
|
|
317
366
|
}
|
|
318
367
|
});
|
|
319
|
-
|
|
320
|
-
// Example 3: Enable only mouse and keystroke tracking, without patching fetch.
|
|
321
|
-
initializeClient({
|
|
322
|
-
mouse: true,
|
|
323
|
-
keystrokes: true
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
// Example 4: Disable keystroke tracking but keep other defaults.
|
|
327
|
-
initializeClient({
|
|
328
|
-
keystrokes: false,
|
|
329
|
-
fetch: {}
|
|
330
|
-
});
|
|
331
368
|
```
|
|
332
369
|
|
|
333
370
|
### Client-Side Behavioral Analysis
|
|
@@ -352,15 +389,15 @@ This provides a proactive, first-line defense against simple bots. By setting up
|
|
|
352
389
|
If you prefer not to modify global functions or need fine-grained control over which requests are protected, you can use the `protectedFetch` wrapper. You must use this function instead of the standard `fetch` for your API calls.
|
|
353
390
|
|
|
354
391
|
- **`protectedFetch(resource, options)`**: A wrapper around the native `fetch` API that automatically enriches requests with security headers. It adds:
|
|
355
|
-
|
|
356
|
-
|
|
392
|
+
- `X-Device-Fingerprint`: The client's device fingerprint.
|
|
393
|
+
- `X-Behavior-Metrics`: A JSON string containing the metrics collected by the behavioral trackers (e.g., mouse entropy, honeypot interaction).
|
|
357
394
|
|
|
358
395
|
**Example:**
|
|
359
396
|
|
|
360
397
|
```javascript
|
|
361
|
-
import {
|
|
362
|
-
|
|
363
|
-
|
|
398
|
+
import {
|
|
399
|
+
initializeClient,
|
|
400
|
+
protectedFetch
|
|
364
401
|
} from './path/to/fingerprint.client.js';
|
|
365
402
|
|
|
366
403
|
// Start tracking user behavior as soon as the app loads.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Algorithme de hachage cyrb53 (rapide et faible taux de collision).
|
|
3
|
+
*/
|
|
4
|
+
export const cyrb53 = (str, seed = 0) => {
|
|
5
|
+
let h1 = 0xdeadbeef ^ seed,
|
|
6
|
+
h2 = 0x41c6ce57 ^ seed;
|
|
7
|
+
for (let i = 0, ch; i < str.length; i++) {
|
|
8
|
+
ch = str.charCodeAt(i);
|
|
9
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
10
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
11
|
+
}
|
|
12
|
+
h1 =
|
|
13
|
+
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
|
|
14
|
+
Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
15
|
+
h2 =
|
|
16
|
+
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
|
|
17
|
+
Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
18
|
+
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Classe pour construire une empreinte composite (Multi-Hash).
|
|
23
|
+
* Format de sortie : "grp1:hash1|grp2:hash2|grp3:hash3"
|
|
24
|
+
*/
|
|
25
|
+
export class FingerprintBuilder {
|
|
26
|
+
constructor() {
|
|
27
|
+
this.components = new Map();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ajoute un composant au hash global.
|
|
32
|
+
* @param {string} group - Le nom du groupe (ex: 'hw', 'screen', 'geo')
|
|
33
|
+
* @param {string|number|boolean} value - La valeur brute à hasher
|
|
34
|
+
*/
|
|
35
|
+
add(group, value) {
|
|
36
|
+
if (value === undefined || value === null) return this;
|
|
37
|
+
// On hash la valeur individuellement pour l'anonymiser et réduire sa taille
|
|
38
|
+
this.components.set(group, cyrb53(String(value)));
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Adds a raw component without hashing it.
|
|
44
|
+
* Useful for metrics that need to be read on the server.
|
|
45
|
+
* @param {string} group - The name of the group.
|
|
46
|
+
* @param {string|number} value - The raw value.
|
|
47
|
+
*/
|
|
48
|
+
addRaw(group, value) {
|
|
49
|
+
if (value === undefined || value === null) return this;
|
|
50
|
+
this.components.set(group, value);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Génère la chaîne de signature finale.
|
|
55
|
+
* Trie les clés pour garantir un ordre déterministe.
|
|
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) => new Map(str.split("|").map(part => part.split(":")).filter(([k,v]) => k && v));
|
|
74
|
+
|
|
75
|
+
const map1 = parse(fpString1);
|
|
76
|
+
const map2 = parse(fpString2);
|
|
77
|
+
|
|
78
|
+
// Poids de "véracité" (Entropie/Stabilité)
|
|
79
|
+
const weights = {
|
|
80
|
+
cvs: 4.0, // Canvas: Très haute entropie (Rendu unique)
|
|
81
|
+
gpu: 3.0, // GPU: Haute entropie (Matériel spécifique)
|
|
82
|
+
hw: 1.5, // Hardware: Moyenne entropie
|
|
83
|
+
scr: 1.0, // Screen: Moyenne
|
|
84
|
+
geo: 0.5, // Geo: Faible (VPN/Voyage)
|
|
85
|
+
os: 0.5, // OS: Faible (Générique)
|
|
86
|
+
bot: 0.0, // Bot: Informatif
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
let weightedMatches = 0;
|
|
90
|
+
let totalWeight = 0;
|
|
91
|
+
|
|
92
|
+
const allKeys = new Set([...map1.keys(), ...map2.keys()]);
|
|
93
|
+
|
|
94
|
+
allKeys.forEach((key) => {
|
|
95
|
+
const weight = weights[key] || 1.0;
|
|
96
|
+
totalWeight += weight;
|
|
97
|
+
if (map1.get(key) === map2.get(key)) {
|
|
98
|
+
weightedMatches += weight;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return totalWeight === 0 ? 0 : weightedMatches / totalWeight;
|
|
103
|
+
}
|
|
104
|
+
}
|
package/fingerprint.client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cyrb53, FingerprintBuilder } from './fingerprint.builder.js';
|
|
2
|
+
import { solveChallenge } from './pow.solver.js';
|
|
2
3
|
|
|
3
4
|
const ClientLibrary = {
|
|
4
5
|
// Cache pour éviter de recalculer les constantes (Hardware, etc.)
|
|
@@ -320,6 +321,44 @@ const ClientLibrary = {
|
|
|
320
321
|
this.addFetchInterceptor(fingerprintInterceptor);
|
|
321
322
|
},
|
|
322
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Intercepte une réponse de challenge JSON, le résout, et réessaie la requête.
|
|
326
|
+
* @param {Response} response - La réponse initiale (potentiellement 429).
|
|
327
|
+
* @param {RequestInfo} resource - La ressource de la requête originale.
|
|
328
|
+
* @param {RequestInit} options - Les options de la requête originale.
|
|
329
|
+
* @returns {Promise<Response>} - La réponse de la requête réessayée.
|
|
330
|
+
* @private
|
|
331
|
+
*/
|
|
332
|
+
async solveChallengeAndRetry(response, resource, options) {
|
|
333
|
+
if (response.status !== 429) {
|
|
334
|
+
return response;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const challengeData = await response.json();
|
|
339
|
+
if (!challengeData.challenge || !challengeData.challenge.type) {
|
|
340
|
+
return response; // Pas un challenge JSON valide
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log(`[Fingerprint] Received a '${challengeData.challenge.type}' challenge. Solving...`);
|
|
344
|
+
const solution = await solveChallenge(challengeData.challenge);
|
|
345
|
+
console.log('[Fingerprint] Challenge solved. Retrying original request.');
|
|
346
|
+
|
|
347
|
+
// Ajouter la solution aux paramètres de la requête pour le nouvel essai
|
|
348
|
+
const url = new URL((resource instanceof Request) ? resource.url : String(resource), window.location.origin);
|
|
349
|
+
url.searchParams.set('pow_type', challengeData.challenge.type);
|
|
350
|
+
url.searchParams.set('pow_nonce', challengeData.challenge.nonce);
|
|
351
|
+
// La solution peut être un objet (pour les challenges combinés) ou une valeur simple
|
|
352
|
+
Object.entries(solution).forEach(([key, value]) => {
|
|
353
|
+
url.searchParams.set(`pow_solution_${key}`, value);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return this._originalFetch(url.toString(), options);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.error('[Fingerprint] Failed to solve or retry challenge:', e);
|
|
359
|
+
return response; // Retourne la réponse 429 originale en cas d'échec
|
|
360
|
+
}
|
|
361
|
+
},
|
|
323
362
|
/**
|
|
324
363
|
* @typedef {object} ClientConfig
|
|
325
364
|
* @property {boolean} [mouse=true] - Activer le suivi de l'entropie de la souris.
|
|
@@ -339,7 +378,7 @@ const ClientLibrary = {
|
|
|
339
378
|
mouse = true,
|
|
340
379
|
keystrokes = true,
|
|
341
380
|
honeypots = [],
|
|
342
|
-
fetch: fetchConfig,
|
|
381
|
+
fetch: fetchConfig = {},
|
|
343
382
|
} = config;
|
|
344
383
|
|
|
345
384
|
if (mouse) {
|
|
@@ -351,8 +390,17 @@ const ClientLibrary = {
|
|
|
351
390
|
if (honeypots.length > 0) {
|
|
352
391
|
this.initializeHoneypots(honeypots);
|
|
353
392
|
}
|
|
354
|
-
|
|
393
|
+
// On vérifie si l'objet fetch est présent pour activer l'interception
|
|
394
|
+
if (Object.keys(fetchConfig).length > 0 || config.fetch) {
|
|
355
395
|
this.initializeFetch(fetchConfig.targetDomains);
|
|
396
|
+
|
|
397
|
+
// Ajoute l'intercepteur pour la résolution de challenge
|
|
398
|
+
if (fetchConfig.handleChallenges !== false) {
|
|
399
|
+
this.addFetchInterceptor(async (resource, options, next) => {
|
|
400
|
+
const response = await next(resource, options);
|
|
401
|
+
return this.solveChallengeAndRetry(response, resource, options);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
356
404
|
}
|
|
357
405
|
}
|
|
358
406
|
};
|
|
@@ -394,6 +442,7 @@ export const addFetchInterceptor = ClientLibrary.addFetchInterceptor.bind(Client
|
|
|
394
442
|
export const patchGlobalFetch = ClientLibrary.patchGlobalFetch.bind(ClientLibrary);
|
|
395
443
|
export const initializeFetch = ClientLibrary.initializeFetch.bind(ClientLibrary);
|
|
396
444
|
export const initializeClient = ClientLibrary.initializeClient.bind(ClientLibrary);
|
|
445
|
+
export const solveChallengeAndRetry = ClientLibrary.solveChallengeAndRetry.bind(ClientLibrary);
|
|
397
446
|
|
|
398
447
|
// Export the internal object for testing purposes
|
|
399
448
|
export default ClientLibrary;
|