@anonympins/fingerprint 0.6.1 → 0.6.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.
- package/CHANGELOG.md +25 -0
- package/README.md +128 -119
- package/composer.json +2 -1
- package/package.json +1 -1
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +452 -109
- package/src/js/library.js +131 -54
- package/src/js/mongodb-store.js +13 -0
- package/src/js/problem-manager.js +6 -2
- package/src/js/redis-store.js +13 -0
- package/src/js/sql-store.js +80 -77
- package/src/js/tests/fingerprint.engine.test.js +2 -3
- package/src/js/tests/fingerprint.test.js +49 -12
- package/src/js/tests/metrics.test.js +157 -103
- package/src/php/Challenge/ChallengeUtils.php +8 -6
- package/src/php/Config/SecurityProfiles.php +8 -2
- package/src/php/FingerprintEngine.php +155 -41
- package/src/php/Optimization/OptimizationOperators.php +60 -36
- package/src/php/RequestContext.php +7 -0
- package/src/php/Store/IStore.php +6 -0
- package/src/php/Store/MongoDbStore.php +12 -0
- package/src/php/Store/RedisStore.php +12 -0
- package/src/php/Tests/ChallengeUtilsTest.php +6 -4
- package/src/php/Tests/FingerprintEngineTest.php +232 -0
- package/src/php/Tests/IpReputationTest.php +1 -0
- package/src/php/Tests/MetricsTest.php +97 -45
- package/src/php/Tests/RequestUtilsTest.php +14 -14
- package/src/php/Tests/config/ed25519_key.json +4 -0
- package/src/php/Utils/Env.php +50 -0
- package/src/php/Utils/MetricsManager.php +209 -166
- package/src/php/Utils/RequestUtils.php +105 -22
package/src/js/library.js
CHANGED
|
@@ -1551,23 +1551,72 @@ Optimization.Operators.solveFraudDetection = (context, options = {}) => {
|
|
|
1551
1551
|
* @param {Array<object>} config.trafficData - Données de trafic collectées.
|
|
1552
1552
|
* @returns {function(object): number[]} Une fonction de fitness qui prend une configuration complète et retourne les scores [taux de faux positifs, taux de faux négatifs].
|
|
1553
1553
|
*/
|
|
1554
|
-
Optimization.Operators.createFullSecurityConfigEvaluator = (
|
|
1555
|
-
|
|
1556
|
-
|
|
1554
|
+
Optimization.Operators.createFullSecurityConfigEvaluator = (context) => {
|
|
1555
|
+
const trafficData = context.trafficData || [];
|
|
1556
|
+
const currentConfig = context.currentConfig || null;
|
|
1557
|
+
|
|
1558
|
+
// Ancres immuables (Baseline Anchors) pour forcer le calibrage d'échelle de suspicion
|
|
1559
|
+
const STATIC_ANCHORS = [
|
|
1560
|
+
{
|
|
1561
|
+
type: 'request_passed',
|
|
1562
|
+
weight: 15.0,
|
|
1563
|
+
vector: {
|
|
1564
|
+
historyScore: 0, rotationScore: 0, headerAnomalyScore: 0, requestPatternScore: 0,
|
|
1565
|
+
inconsistencyScore: 0, behaviorScore: 0, honeypotScore: 0, botScore: 0,
|
|
1566
|
+
crossLayerInconsistencyScore: 0, timeInconsistencyScore: 0, tlsSpoofingScore: 0,
|
|
1567
|
+
clickVarianceScore: 0, clientHintsInconsistencyScore: 0, subnetScore: 0,
|
|
1568
|
+
ipReputationScore: 0, botnetClusterScore: 0, tcpAnomalyScore: 0,
|
|
1569
|
+
quicAnomalyScore: 0, renderingAnomalyScore: 0, threatIntelScore: 0
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
type: 'request_blocked',
|
|
1574
|
+
weight: 15.0,
|
|
1575
|
+
vector: {
|
|
1576
|
+
historyScore: 100, rotationScore: 100, headerAnomalyScore: 80, requestPatternScore: 100,
|
|
1577
|
+
inconsistencyScore: 100, behaviorScore: 0, honeypotScore: 100, botScore: 100,
|
|
1578
|
+
crossLayerInconsistencyScore: 80, timeInconsistencyScore: 100, tlsSpoofingScore: 100,
|
|
1579
|
+
clickVarianceScore: 100, clientHintsInconsistencyScore: 90, subnetScore: 100,
|
|
1580
|
+
ipReputationScore: 100, botnetClusterScore: 100, tcpAnomalyScore: 100,
|
|
1581
|
+
quicAnomalyScore: 100, renderingAnomalyScore: 100, threatIntelScore: 100
|
|
1582
|
+
}
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
type: 'request_passed', // Profil humain sain avec du bruit (doit rester sous le seuil d'alerte, ex: < 20)
|
|
1586
|
+
weight: 10.0,
|
|
1587
|
+
vector: {
|
|
1588
|
+
historyScore: 15, rotationScore: 10, headerAnomalyScore: 20, requestPatternScore: 15,
|
|
1589
|
+
inconsistencyScore: 10, behaviorScore: 15, honeypotScore: 0, botScore: 0,
|
|
1590
|
+
crossLayerInconsistencyScore: 10, timeInconsistencyScore: 10, tlsSpoofingScore: 10,
|
|
1591
|
+
clickVarianceScore: 0, clientHintsInconsistencyScore: 10, subnetScore: 5,
|
|
1592
|
+
ipReputationScore: 5, botnetClusterScore: 5, tcpAnomalyScore: 10,
|
|
1593
|
+
quicAnomalyScore: 10, renderingAnomalyScore: 5, threatIntelScore: 0
|
|
1594
|
+
}
|
|
1595
|
+
},
|
|
1596
|
+
{
|
|
1597
|
+
type: 'challenge_issued', // Profil robot furtif moyen (doit être challengé, ex: > 35)
|
|
1598
|
+
weight: 10.0,
|
|
1599
|
+
vector: {
|
|
1600
|
+
historyScore: 20, rotationScore: 20, headerAnomalyScore: 20, requestPatternScore: 40,
|
|
1601
|
+
inconsistencyScore: 30, behaviorScore: 30, honeypotScore: 0, botScore: 0,
|
|
1602
|
+
crossLayerInconsistencyScore: 20, timeInconsistencyScore: 20, tlsSpoofingScore: 30,
|
|
1603
|
+
clickVarianceScore: 20, clientHintsInconsistencyScore: 20, subnetScore: 15,
|
|
1604
|
+
ipReputationScore: 15, botnetClusterScore: 20, tcpAnomalyScore: 30,
|
|
1605
|
+
quicAnomalyScore: 20, renderingAnomalyScore: 20, threatIntelScore: 0
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
];
|
|
1557
1609
|
|
|
1558
1610
|
return function fullConfigFitness(config) {
|
|
1559
|
-
let falsePositives = 0;
|
|
1560
|
-
let falseNegatives = 0;
|
|
1611
|
+
let falsePositives = 0;
|
|
1612
|
+
let falseNegatives = 0;
|
|
1561
1613
|
let totalHumans = 0;
|
|
1562
1614
|
let totalBots = 0;
|
|
1563
|
-
let totalChallenges = 0;
|
|
1564
|
-
let totalChallengeCost = 0; // NOUVEAU: Coût cumulé des challenges
|
|
1565
1615
|
|
|
1566
|
-
|
|
1616
|
+
let maxHumanScore = 0;
|
|
1617
|
+
let minBotScore = 100;
|
|
1618
|
+
|
|
1567
1619
|
const calculateScore = (log) => {
|
|
1568
|
-
// Cette fonction interne devrait répliquer la logique de `getSuspicionVector`
|
|
1569
|
-
// en utilisant les poids et les paramètres de `config`.
|
|
1570
|
-
// Pour cet exemple, nous utilisons une version simplifiée.
|
|
1571
1620
|
let score = 0;
|
|
1572
1621
|
for (const key in config.weights) {
|
|
1573
1622
|
score += (log.vector?.[key] || 0) * config.weights[key];
|
|
@@ -1575,57 +1624,78 @@ Optimization.Operators.createFullSecurityConfigEvaluator = ({ trafficData }) =>
|
|
|
1575
1624
|
return score;
|
|
1576
1625
|
};
|
|
1577
1626
|
|
|
1578
|
-
// Pondération de la confiance : un challenge résolu est un signal humain plus fort.
|
|
1579
1627
|
const confidenceWeights = {
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1628
|
+
request_passed: 0.7,
|
|
1629
|
+
challenge_issued: 1.0,
|
|
1630
|
+
request_blocked: 1.0,
|
|
1631
|
+
challenge_solved: 1.5,
|
|
1632
|
+
trap_triggered: 2.0,
|
|
1585
1633
|
};
|
|
1586
1634
|
|
|
1635
|
+
// 1. Évaluation sur les données de trafic réelles
|
|
1587
1636
|
for (const log of trafficData) {
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
const isLikelyBot = log.type === 'challenge_issued' || log.type === 'request_blocked';
|
|
1637
|
+
const weight = log.weight || 1.0;
|
|
1638
|
+
const confidence = (confidenceWeights[log.type] || 1.0) * weight;
|
|
1639
|
+
const isLikelyBot = log.type === 'challenge_issued' || log.type === 'request_blocked' || log.type === 'trap_triggered';
|
|
1592
1640
|
const isLikelyHuman = log.type === 'request_passed' || log.type === 'challenge_solved';
|
|
1593
1641
|
|
|
1642
|
+
const score = calculateScore(log);
|
|
1643
|
+
|
|
1594
1644
|
if (isLikelyBot) {
|
|
1595
|
-
totalBots
|
|
1596
|
-
|
|
1597
|
-
// Faux négatif : un bot qui aurait dû être challengé mais ne l'a pas été
|
|
1645
|
+
totalBots += confidence;
|
|
1646
|
+
minBotScore = Math.min(minBotScore, score);
|
|
1598
1647
|
if (score < config.thresholds.low) {
|
|
1599
1648
|
falseNegatives += confidence;
|
|
1600
|
-
} else {
|
|
1601
|
-
totalChallenges += confidence; // Un bot correctement challengé
|
|
1602
|
-
// Calculer le coût de ce challenge (proportionnel à la difficulté)
|
|
1603
|
-
const suspicionFactor = (score - config.thresholds.low) / (config.thresholds.high - config.thresholds.low);
|
|
1604
|
-
totalChallengeCost += Math.min(1, Math.max(0, suspicionFactor)) * confidence;
|
|
1605
1649
|
}
|
|
1606
1650
|
} else if (isLikelyHuman) {
|
|
1607
|
-
totalHumans
|
|
1608
|
-
|
|
1609
|
-
// Faux positif : un humain qui a été challengé inutilement
|
|
1651
|
+
totalHumans += confidence;
|
|
1652
|
+
maxHumanScore = Math.max(maxHumanScore, score);
|
|
1610
1653
|
if (score >= config.thresholds.low) {
|
|
1611
1654
|
falsePositives += confidence;
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// 2. Évaluation sur les ancres immuables pour fixer l'échelle
|
|
1660
|
+
for (const anchor of STATIC_ANCHORS) {
|
|
1661
|
+
const score = calculateScore(anchor);
|
|
1662
|
+
const weight = anchor.weight;
|
|
1663
|
+
|
|
1664
|
+
if (anchor.type === 'request_blocked' || anchor.type === 'challenge_issued') {
|
|
1665
|
+
totalBots += weight;
|
|
1666
|
+
minBotScore = Math.min(minBotScore, score);
|
|
1667
|
+
if (score < config.thresholds.low) {
|
|
1668
|
+
falseNegatives += weight * 10; // Pénalité punitive forte
|
|
1669
|
+
}
|
|
1670
|
+
} else {
|
|
1671
|
+
totalHumans += weight;
|
|
1672
|
+
maxHumanScore = Math.max(maxHumanScore, score);
|
|
1673
|
+
if (score >= config.thresholds.low) {
|
|
1674
|
+
falsePositives += weight * 10; // Pénalité punitive forte
|
|
1615
1675
|
}
|
|
1616
1676
|
}
|
|
1617
1677
|
}
|
|
1618
1678
|
|
|
1619
1679
|
const falsePositiveRate = totalHumans > 0 ? falsePositives / totalHumans : 0;
|
|
1620
1680
|
const falseNegativeRate = totalBots > 0 ? falseNegatives / totalBots : 0;
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1681
|
+
|
|
1682
|
+
// 3. Pénalité de dérive d'échelle L2 (Régularisation par rapport au point d'origine)
|
|
1683
|
+
let regularizationPenalty = 0;
|
|
1684
|
+
if (currentConfig && currentConfig.weights) {
|
|
1685
|
+
for (const key in config.weights) {
|
|
1686
|
+
const originalVal = currentConfig.weights[key] || 0;
|
|
1687
|
+
regularizationPenalty += Math.pow(config.weights[key] - originalVal, 2);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// 4. Maximisation de la marge de séparation (SVM-like Margin Loss)
|
|
1692
|
+
const marginOverlap = Math.max(0, maxHumanScore - minBotScore);
|
|
1693
|
+
const marginPenalty = marginOverlap / 100;
|
|
1694
|
+
|
|
1695
|
+
const obj1 = falsePositiveRate + (regularizationPenalty * 0.05);
|
|
1696
|
+
const obj2 = falseNegativeRate + marginPenalty;
|
|
1697
|
+
|
|
1698
|
+
return [obj1, obj2];
|
|
1629
1699
|
};
|
|
1630
1700
|
};
|
|
1631
1701
|
|
|
@@ -1646,17 +1716,24 @@ Optimization.Operators.solveFullSecurityTuning = (context, options = {}) => {
|
|
|
1646
1716
|
high: 70 + secureRandom() * 20,
|
|
1647
1717
|
},
|
|
1648
1718
|
weights: {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1719
|
+
historyScore: secureRandom(),
|
|
1720
|
+
rotationScore: secureRandom(),
|
|
1721
|
+
headerAnomalyScore: secureRandom(),
|
|
1722
|
+
requestPatternScore: 0.5 + secureRandom(),
|
|
1723
|
+
inconsistencyScore: secureRandom(),
|
|
1724
|
+
honeypotScore: 1.0, // Garder le honeypot à 1.0 est une bonne pratique
|
|
1725
|
+
behaviorScore: secureRandom(),
|
|
1726
|
+
crossLayerInconsistencyScore: secureRandom(),
|
|
1727
|
+
timeInconsistencyScore: secureRandom(),
|
|
1728
|
+
tlsSpoofingScore: secureRandom(),
|
|
1729
|
+
botScore: secureRandom(),
|
|
1730
|
+
subnetScore: secureRandom(),
|
|
1731
|
+
ipReputationScore: secureRandom(),
|
|
1732
|
+
botnetClusterScore: secureRandom(),
|
|
1733
|
+
tcpAnomalyScore: secureRandom(),
|
|
1734
|
+
quicAnomalyScore: secureRandom(),
|
|
1735
|
+
clickVarianceScore: secureRandom(),
|
|
1736
|
+
clientHintsInconsistencyScore: secureRandom()
|
|
1660
1737
|
},
|
|
1661
1738
|
patterns: {
|
|
1662
1739
|
velocityThreshold: 100 + secureRandom() * 400,
|
package/src/js/mongodb-store.js
CHANGED
|
@@ -154,6 +154,19 @@ export function createMongoDbStore(db, collectionName = 'fingerprint_store') {
|
|
|
154
154
|
triggerFallback();
|
|
155
155
|
}
|
|
156
156
|
},
|
|
157
|
+
async clear() {
|
|
158
|
+
localStore.clear();
|
|
159
|
+
for (const timeout of localTimeouts.values()) {
|
|
160
|
+
clearTimeout(timeout);
|
|
161
|
+
}
|
|
162
|
+
localTimeouts.clear();
|
|
163
|
+
if (isDown) return;
|
|
164
|
+
try {
|
|
165
|
+
await collection.deleteMany({});
|
|
166
|
+
} catch (e) {
|
|
167
|
+
triggerFallback();
|
|
168
|
+
}
|
|
169
|
+
},
|
|
157
170
|
async init() {
|
|
158
171
|
try {
|
|
159
172
|
// Automates index configuration
|
|
@@ -48,8 +48,12 @@ FunctionRegistry['facility.calculateEnergy'] = (facilities, payload) => {
|
|
|
48
48
|
* @param {Array<{x: number, y: number}>} path - Un tableau de points représentant le chemin.
|
|
49
49
|
* @returns {number} La distance totale du chemin.
|
|
50
50
|
*/
|
|
51
|
-
FunctionRegistry['tsp.calculateEnergy'] = (path) => {
|
|
52
|
-
|
|
51
|
+
FunctionRegistry['tsp.calculateEnergy'] = (path, payload) => {
|
|
52
|
+
const cities = payload?.cities || payload?.points || [];
|
|
53
|
+
if (path && typeof path[0] === 'number') {
|
|
54
|
+
return Optimization.Utils.evaluatePathDistance(cities, path);
|
|
55
|
+
}
|
|
56
|
+
// Fallback: Crée un tableau d'indices [0, 1, 2, ...] pour la fonction evaluatePathDistance.
|
|
53
57
|
const indices = Array.from({ length: path.length }, (_, i) => i);
|
|
54
58
|
return Optimization.Utils.evaluatePathDistance(path, indices);
|
|
55
59
|
};
|
package/src/js/redis-store.js
CHANGED
|
@@ -111,5 +111,18 @@ export function createRedisStore(redisClient) {
|
|
|
111
111
|
triggerFallback();
|
|
112
112
|
}
|
|
113
113
|
},
|
|
114
|
+
async clear() {
|
|
115
|
+
localStore.clear();
|
|
116
|
+
for (const timeout of localTimeouts.values()) {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
}
|
|
119
|
+
localTimeouts.clear();
|
|
120
|
+
if (isDown) return;
|
|
121
|
+
try {
|
|
122
|
+
await redisClient.flushdb();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
triggerFallback();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
114
127
|
};
|
|
115
128
|
}
|
package/src/js/sql-store.js
CHANGED
|
@@ -1,78 +1,81 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Creates a store adapter for Knex.js.
|
|
3
|
-
* This adapter is compatible with various SQL databases like PostgreSQL, MySQL, and SQLite.
|
|
4
|
-
* It handles serialization of complex objects and TTL for automatic data expiration.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Creates a store adapter for a Knex.js client.
|
|
9
|
-
*
|
|
10
|
-
* **Note:** You must create the table yourself before using the store.
|
|
11
|
-
* The table should have at least the following columns:
|
|
12
|
-
* - `key` (string, primary key)
|
|
13
|
-
* - `value` (text or json/jsonb)
|
|
14
|
-
* - `expiresAt` (datetime or timestamp with time zone)
|
|
15
|
-
*
|
|
16
|
-
* Example schema for PostgreSQL:
|
|
17
|
-
* ```sql
|
|
18
|
-
* CREATE TABLE your_table_name (
|
|
19
|
-
* "key" VARCHAR(255) PRIMARY KEY,
|
|
20
|
-
* "value" TEXT NOT NULL,
|
|
21
|
-
* "expiresAt" TIMESTAMPTZ
|
|
22
|
-
* );
|
|
23
|
-
* ```
|
|
24
|
-
*
|
|
25
|
-
* @param {import('knex').Knex} knex - An instance of the Knex client.
|
|
26
|
-
* @param {string} [tableName='fingerprint_store'] - The name of the table to use.
|
|
27
|
-
* @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
|
|
28
|
-
*/
|
|
29
|
-
export function createSqlStore(knex, tableName = 'fingerprint_store') {
|
|
30
|
-
// Custom replacer/reviver to handle Set serialization, similar to the Redis store.
|
|
31
|
-
const replacer = (k, v) => (v instanceof Set ? Array.from(v) : v);
|
|
32
|
-
const reviver = (k, v) => (k === 'ips' && Array.isArray(v) ? new Set(v) : v);
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
async get(key) {
|
|
36
|
-
const row = await knex(tableName).where('key', key).first();
|
|
37
|
-
if (!row) return null;
|
|
38
|
-
|
|
39
|
-
// Manually check for expiration, as not all SQL databases have automatic TTL cleanup.
|
|
40
|
-
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
|
|
41
|
-
await this.delete(key); // Clean up expired key.
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
return JSON.parse(row.value, reviver);
|
|
47
|
-
} catch (e) {
|
|
48
|
-
// In case of malformed JSON, treat it as a miss.
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
|
|
53
|
-
async set(key, value, ttl) {
|
|
54
|
-
const stringValue = JSON.stringify(value, replacer);
|
|
55
|
-
const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : null;
|
|
56
|
-
|
|
57
|
-
// Use native upsert capabilities of Knex for different SQL dialects.
|
|
58
|
-
await knex(tableName)
|
|
59
|
-
.insert({ key, value: stringValue, expiresAt })
|
|
60
|
-
.onConflict('key')
|
|
61
|
-
.merge();
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
async has(key) {
|
|
65
|
-
const row = await knex(tableName).where('key', key).first('key');
|
|
66
|
-
if (!row) return false;
|
|
67
|
-
// Also check for expiration here.
|
|
68
|
-
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
return true;
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
async delete(key) {
|
|
75
|
-
await knex(tableName).where('key', key).del();
|
|
76
|
-
},
|
|
77
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Creates a store adapter for Knex.js.
|
|
3
|
+
* This adapter is compatible with various SQL databases like PostgreSQL, MySQL, and SQLite.
|
|
4
|
+
* It handles serialization of complex objects and TTL for automatic data expiration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a store adapter for a Knex.js client.
|
|
9
|
+
*
|
|
10
|
+
* **Note:** You must create the table yourself before using the store.
|
|
11
|
+
* The table should have at least the following columns:
|
|
12
|
+
* - `key` (string, primary key)
|
|
13
|
+
* - `value` (text or json/jsonb)
|
|
14
|
+
* - `expiresAt` (datetime or timestamp with time zone)
|
|
15
|
+
*
|
|
16
|
+
* Example schema for PostgreSQL:
|
|
17
|
+
* ```sql
|
|
18
|
+
* CREATE TABLE your_table_name (
|
|
19
|
+
* "key" VARCHAR(255) PRIMARY KEY,
|
|
20
|
+
* "value" TEXT NOT NULL,
|
|
21
|
+
* "expiresAt" TIMESTAMPTZ
|
|
22
|
+
* );
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @param {import('knex').Knex} knex - An instance of the Knex client.
|
|
26
|
+
* @param {string} [tableName='fingerprint_store'] - The name of the table to use.
|
|
27
|
+
* @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
|
|
28
|
+
*/
|
|
29
|
+
export function createSqlStore(knex, tableName = 'fingerprint_store') {
|
|
30
|
+
// Custom replacer/reviver to handle Set serialization, similar to the Redis store.
|
|
31
|
+
const replacer = (k, v) => (v instanceof Set ? Array.from(v) : v);
|
|
32
|
+
const reviver = (k, v) => (k === 'ips' && Array.isArray(v) ? new Set(v) : v);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
async get(key) {
|
|
36
|
+
const row = await knex(tableName).where('key', key).first();
|
|
37
|
+
if (!row) return null;
|
|
38
|
+
|
|
39
|
+
// Manually check for expiration, as not all SQL databases have automatic TTL cleanup.
|
|
40
|
+
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
|
|
41
|
+
await this.delete(key); // Clean up expired key.
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(row.value, reviver);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// In case of malformed JSON, treat it as a miss.
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async set(key, value, ttl) {
|
|
54
|
+
const stringValue = JSON.stringify(value, replacer);
|
|
55
|
+
const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : null;
|
|
56
|
+
|
|
57
|
+
// Use native upsert capabilities of Knex for different SQL dialects.
|
|
58
|
+
await knex(tableName)
|
|
59
|
+
.insert({ key, value: stringValue, expiresAt })
|
|
60
|
+
.onConflict('key')
|
|
61
|
+
.merge();
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async has(key) {
|
|
65
|
+
const row = await knex(tableName).where('key', key).first('key');
|
|
66
|
+
if (!row) return false;
|
|
67
|
+
// Also check for expiration here.
|
|
68
|
+
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async delete(key) {
|
|
75
|
+
await knex(tableName).where('key', key).del();
|
|
76
|
+
},
|
|
77
|
+
async clear() {
|
|
78
|
+
await knex(tableName).del();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
78
81
|
}
|
|
@@ -92,8 +92,7 @@ describe('FingerprintEngine Challenge Validation', () => {
|
|
|
92
92
|
|
|
93
93
|
// The client solves the challenge
|
|
94
94
|
// --- FIX: Simulate the client creating the baseBlock for the solver ---
|
|
95
|
-
const
|
|
96
|
-
const baseBlock = new TextEncoder().encode(messageBase);
|
|
95
|
+
const baseBlock = new Uint8Array(challengeContext.baseBlock);
|
|
97
96
|
|
|
98
97
|
const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTarget, null);
|
|
99
98
|
// The memory challenge seed does not include the fingerprint.
|
|
@@ -153,7 +152,7 @@ describe('FingerprintEngine Challenge Validation', () => {
|
|
|
153
152
|
// --- 2. Second request: Solution is submitted from "Machine B" ---
|
|
154
153
|
// --- FIX: Simulate the client creating the baseBlock for the solver ---
|
|
155
154
|
// The solver uses the fingerprint of the machine it's running on.
|
|
156
|
-
const messageBase = `${nonce}:${challengeContext.clientSecret}:fingerprint-B
|
|
155
|
+
const messageBase = `${nonce}:${challengeContext.clientSecret}:fingerprint-B:127.0.0.1::`;
|
|
157
156
|
const baseBlock = new TextEncoder().encode(messageBase);
|
|
158
157
|
|
|
159
158
|
const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTarget, null);
|
|
@@ -739,7 +739,7 @@ describe('Fingerprint & PoW Security Suite', () => {
|
|
|
739
739
|
const memDifficulty = parseInt(challengeBody.match(/const memDifficulty = (\d+)/)[1], 10);
|
|
740
740
|
|
|
741
741
|
// The client constructs the base block and solves the challenge
|
|
742
|
-
const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}
|
|
742
|
+
const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:${ip}::`);
|
|
743
743
|
const cpuSolution = await solveCpuTargetInline(baseBlock, cpuTargetHex, () => {});
|
|
744
744
|
const memSolution = await solveMemory(`:${nonce}:${clientSecret}`, memDifficulty);
|
|
745
745
|
|
|
@@ -821,7 +821,7 @@ describe('Fingerprint & PoW Security Suite', () => {
|
|
|
821
821
|
const memDifficulty = parseInt(challengeBody.match(/const memDifficulty = (\d+)/)[1], 10);
|
|
822
822
|
|
|
823
823
|
// The server stores the challenge context. We need to ensure the `baseBlock` is present for verification.
|
|
824
|
-
const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}
|
|
824
|
+
const baseBlock = new TextEncoder().encode(`${nonce}:${clientSecret}:${solverFingerprint}:${ip}::`);
|
|
825
825
|
await inMemoryStore.set(`secret:${nonce}`, {
|
|
826
826
|
clientSecret: clientSecret,
|
|
827
827
|
cpuTarget: cpuTargetHex,
|
|
@@ -2405,7 +2405,7 @@ describe('Subnet Scoring (Node.js)', () => {
|
|
|
2405
2405
|
deviceIds: ['d1', 'd2'], // count < 10, score += 0
|
|
2406
2406
|
});
|
|
2407
2407
|
({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
|
|
2408
|
-
expect(subnetScore).toBe(
|
|
2408
|
+
expect(subnetScore).toBe(100);
|
|
2409
2409
|
|
|
2410
2410
|
// 3. Many devices and high scores
|
|
2411
2411
|
const deviceIds = Array.from({ length: 20 }, (_, i) => `d${i}`);
|
|
@@ -2414,7 +2414,7 @@ describe('Subnet Scoring (Node.js)', () => {
|
|
|
2414
2414
|
deviceIds: deviceIds, // count = 20. score += (20-10)*5 = 50
|
|
2415
2415
|
});
|
|
2416
2416
|
({ subnetScore } = await __internal.getSubnetScore(context, 'device-1'));
|
|
2417
|
-
expect(subnetScore).toBe(
|
|
2417
|
+
expect(subnetScore).toBe(100);
|
|
2418
2418
|
});
|
|
2419
2419
|
});
|
|
2420
2420
|
|
|
@@ -2714,18 +2714,18 @@ describe('Botnet Cluster Scoring (Node.js)', () => {
|
|
|
2714
2714
|
// 2. Ajout de 2 IPs uniques (total 3)
|
|
2715
2715
|
await getBotnetClusterScore({ clientIp: '192.168.1.2' }, stableFpHash);
|
|
2716
2716
|
scoreData = await getBotnetClusterScore({ clientIp: '192.168.1.3' }, stableFpHash);
|
|
2717
|
-
expect(scoreData.botnetClusterScore).toBe(
|
|
2717
|
+
expect(scoreData.botnetClusterScore).toBe(30.2);
|
|
2718
2718
|
|
|
2719
2719
|
// 3. Ajout de 2 IPs uniques (total 5)
|
|
2720
2720
|
await getBotnetClusterScore({ clientIp: '192.168.1.4' }, stableFpHash);
|
|
2721
2721
|
scoreData = await getBotnetClusterScore({ clientIp: '192.168.1.5' }, stableFpHash);
|
|
2722
|
-
expect(scoreData.botnetClusterScore).toBe(
|
|
2722
|
+
expect(scoreData.botnetClusterScore).toBe(45.2);
|
|
2723
2723
|
|
|
2724
2724
|
// 4. Ajout de 5 IPs uniques (total 10)
|
|
2725
2725
|
for (let i = 6; i <= 10; i++) {
|
|
2726
2726
|
scoreData = await getBotnetClusterScore({ clientIp: `192.168.1.${i}` }, stableFpHash);
|
|
2727
2727
|
}
|
|
2728
|
-
expect(scoreData.botnetClusterScore).toBe(
|
|
2728
|
+
expect(scoreData.botnetClusterScore).toBe(57.4);
|
|
2729
2729
|
});
|
|
2730
2730
|
|
|
2731
2731
|
it('should realistically group PS4 consoles with volatile differences (different IPs/cookies) under the same cluster score', async () => {
|
|
@@ -2759,15 +2759,15 @@ describe('Botnet Cluster Scoring (Node.js)', () => {
|
|
|
2759
2759
|
if (i === 1) {
|
|
2760
2760
|
expect(vector.botnetClusterScore).toBe(0);
|
|
2761
2761
|
} else if (i === 2) {
|
|
2762
|
-
expect(vector.botnetClusterScore).toBe(
|
|
2762
|
+
expect(vector.botnetClusterScore).toBe(17.7);
|
|
2763
2763
|
} else if (i === 3) {
|
|
2764
|
-
expect(vector.botnetClusterScore).toBe(
|
|
2764
|
+
expect(vector.botnetClusterScore).toBe(30.2);
|
|
2765
2765
|
} else if (i === 4) {
|
|
2766
|
-
expect(vector.botnetClusterScore).toBe(
|
|
2766
|
+
expect(vector.botnetClusterScore).toBe(39.0);
|
|
2767
2767
|
} else if (i === 5) {
|
|
2768
|
-
expect(vector.botnetClusterScore).toBe(
|
|
2768
|
+
expect(vector.botnetClusterScore).toBe(45.2);
|
|
2769
2769
|
} else if (i === 10) {
|
|
2770
|
-
expect(vector.botnetClusterScore).toBe(
|
|
2770
|
+
expect(vector.botnetClusterScore).toBe(57.4);
|
|
2771
2771
|
}
|
|
2772
2772
|
}
|
|
2773
2773
|
});
|
|
@@ -2874,4 +2874,41 @@ describe('IP Registration and Filtering', () => {
|
|
|
2874
2874
|
expect(storedData.ips.has(ip1)).toBe(true);
|
|
2875
2875
|
expect(storedData.ips.has(ip2)).toBe(true);
|
|
2876
2876
|
});
|
|
2877
|
+
|
|
2878
|
+
describe('Ed25519 Key Generation & Reopening', () => {
|
|
2879
|
+
beforeEach(() => {
|
|
2880
|
+
delete process.env.ED25519_PRIVATE_KEY;
|
|
2881
|
+
delete process.env.ED25519_PUBLIC_KEY;
|
|
2882
|
+
vi.restoreAllMocks();
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
it('should generate new keys and save to disk if not present', async () => {
|
|
2886
|
+
const fs = await import('node:fs');
|
|
2887
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
2888
|
+
const writeSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
|
|
2889
|
+
|
|
2890
|
+
const config = { ed25519: 'auto', weights: {}, thresholds: {} };
|
|
2891
|
+
new FingerprintEngine(config);
|
|
2892
|
+
|
|
2893
|
+
expect(process.env.ED25519_PRIVATE_KEY).toBeDefined();
|
|
2894
|
+
expect(process.env.ED25519_PUBLIC_KEY).toBeDefined();
|
|
2895
|
+
expect(writeSpy).toHaveBeenCalled();
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
it('should load keys from disk if they already exist', async () => {
|
|
2899
|
+
const fs = await import('node:fs');
|
|
2900
|
+
const dummyKeys = {
|
|
2901
|
+
privateKey: '-----BEGIN PRIVATE KEY-----\ndummy-private-key\n-----END PRIVATE KEY-----',
|
|
2902
|
+
publicKey: '-----BEGIN PUBLIC KEY-----\ndummy-public-key\n-----END PUBLIC KEY-----'
|
|
2903
|
+
};
|
|
2904
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
|
2905
|
+
const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(dummyKeys));
|
|
2906
|
+
|
|
2907
|
+
const config = { ed25519: 'auto', weights: {}, thresholds: {} };
|
|
2908
|
+
new FingerprintEngine(config);
|
|
2909
|
+
|
|
2910
|
+
expect(process.env.ED25519_PRIVATE_KEY).toBe(dummyKeys.privateKey);
|
|
2911
|
+
expect(process.env.ED25519_PUBLIC_KEY).toBe(dummyKeys.publicKey);
|
|
2912
|
+
});
|
|
2913
|
+
});
|
|
2877
2914
|
});
|