@anonympins/fingerprint 0.8.0 → 0.8.2
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 +54 -0
- package/README.md +1 -1
- package/package.json +4 -2
- package/public/anonympins-bot-mitigation-pow.zip +0 -0
- package/src/js/asn-lookup.js +223 -0
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +214 -29
- package/src/js/tests/asn-lookup.test.js +57 -0
- package/src/php/AsnLookupEngine.php +173 -0
- package/src/php/Challenge/ChallengeUtils.php +162 -64
- package/src/php/DirectFingerprint.php +25 -5
- package/src/php/FingerprintEngine.php +126 -30
- package/src/php/NetworkProfile.php +68 -0
- package/src/php/ProblemManager.php +12 -2
- package/src/php/Tests/AsnLookupTest.php +61 -0
- package/src/php/Tests/FingerprintEngineTest.php +4 -2
- package/src/php/Utils/Env.php +7 -2
- package/src/php/Utils/RequestUtils.php +24 -6
- package/src/php/WordPress/WpDbStore.php +27 -0
- package/src/php/WordPress/anonympins-bot-mitigation-pow.php +303 -64
- package/src/php/WordPress/languages/anonympins-bot-mitigation-pow-fr_FR.po +1 -1
- package/src/php/WordPress/languages/fingerprint-wordpress-de_DE.po +1 -1
- package/src/php/WordPress/languages/fingerprint-wordpress-fr_FR.po +1 -1
- package/src/php/WordPress/package.php +10 -0
- package/src/php/WordPress/readme.txt +7 -1
package/src/js/fingerprint.js
CHANGED
|
@@ -5,6 +5,7 @@ import {Worker} from "node:worker_threads";
|
|
|
5
5
|
import {getProblemManager, problemManager} from "./problem-manager.js";
|
|
6
6
|
import {Optimization} from "./library.js";
|
|
7
7
|
import {cyrb53, FingerprintBuilder} from "./fingerprint.builder.js";
|
|
8
|
+
import {defaultAsnLookup, NetworkProfile} from "./asn-lookup.js";
|
|
8
9
|
import {DynamicWasmGenerator} from "./dynamic-wasm.js";
|
|
9
10
|
import {writeFileSync, readFileSync, existsSync, promises as fsPromises} from "node:fs";
|
|
10
11
|
import {fileURLToPath} from "node:url";
|
|
@@ -3438,18 +3439,26 @@ async function updateSubnetMetrics(context, deviceId, finalScore) {
|
|
|
3438
3439
|
/**
|
|
3439
3440
|
* Calcule un score d'incohérence analogique lisse plafonnant à une asymptote de 99.9.
|
|
3440
3441
|
* @param {number} consistencyScore Similarité entre 0 et 1 issue du FingerprintBuilder.
|
|
3441
|
-
* @param {number} [
|
|
3442
|
+
* @param {number|object} [inflectionOrProfile=0.72] Point d'inflexion ou profil réseau/IP.
|
|
3442
3443
|
* @param {number} [steepness=12] Raideur de la transition sigmoïdale.
|
|
3443
3444
|
* @returns {number} Score de 0 à 99.9 sans saut de palier ni certitude absolue à 100.
|
|
3444
3445
|
*/
|
|
3445
|
-
export function calculateAnalogInconsistencyScore(consistencyScore,
|
|
3446
|
+
export function calculateAnalogInconsistencyScore(consistencyScore, inflectionOrProfile = 0.72, steepness = 12) {
|
|
3446
3447
|
const s = Math.max(0.0, Math.min(1.0, consistencyScore));
|
|
3447
3448
|
if (s >= 0.98) return 0.0;
|
|
3448
3449
|
|
|
3450
|
+
let inflectionPoint = typeof inflectionOrProfile === 'number'
|
|
3451
|
+
? inflectionOrProfile
|
|
3452
|
+
: (inflectionOrProfile?.inflectionPoint ?? 0.72);
|
|
3453
|
+
|
|
3454
|
+
const k = typeof inflectionOrProfile === 'object' && inflectionOrProfile?.steepness !== undefined
|
|
3455
|
+
? inflectionOrProfile.steepness
|
|
3456
|
+
: steepness;
|
|
3457
|
+
|
|
3449
3458
|
const asymptote = 99.9;
|
|
3450
|
-
const raw = 1.0 / (1.0 + Math.exp(
|
|
3451
|
-
const minVal = 1.0 / (1.0 + Math.exp(
|
|
3452
|
-
const maxVal = 1.0 / (1.0 + Math.exp(
|
|
3459
|
+
const raw = 1.0 / (1.0 + Math.exp(k * (s - inflectionPoint)));
|
|
3460
|
+
const minVal = 1.0 / (1.0 + Math.exp(k * (1.0 - inflectionPoint)));
|
|
3461
|
+
const maxVal = 1.0 / (1.0 + Math.exp(k * (0.0 - inflectionPoint)));
|
|
3453
3462
|
const normalized = ((raw - minVal) / (maxVal - minVal)) * asymptote;
|
|
3454
3463
|
|
|
3455
3464
|
return Math.min(asymptote, Math.round(normalized * 10.0) / 10.0);
|
|
@@ -3625,13 +3634,14 @@ async function getBotnetClusterScore(context, stableFpHash) {
|
|
|
3625
3634
|
*/
|
|
3626
3635
|
async function getIpReputationScore(ip) {
|
|
3627
3636
|
const key = `ip-reputation:${ip}`;
|
|
3637
|
+
const netProfile = defaultAsnLookup.lookup(ip);
|
|
3628
3638
|
const data = await store.get(key);
|
|
3629
|
-
if (!data) return
|
|
3639
|
+
if (!data) return netProfile.baseScore;
|
|
3630
3640
|
|
|
3631
3641
|
const now = Date.now();
|
|
3632
3642
|
const hoursPassed = (now - data.lastUpdate) / (1000 * 60 * 60);
|
|
3633
3643
|
const decay = Math.floor(hoursPassed * 2); // Decay 2 points per hour of inactivity
|
|
3634
|
-
return Math.max(
|
|
3644
|
+
return Math.max(netProfile.baseScore, data.score - decay);
|
|
3635
3645
|
}
|
|
3636
3646
|
|
|
3637
3647
|
/**
|
|
@@ -3875,9 +3885,27 @@ let store = inMemoryStore;
|
|
|
3875
3885
|
* @param {IStore} externalStore - An implementation of the IStore interface.
|
|
3876
3886
|
*/
|
|
3877
3887
|
export const configureStore = (externalStore) => {
|
|
3888
|
+
if (externalStore && typeof externalStore.has !== 'function' && typeof externalStore.get === 'function') {
|
|
3889
|
+
externalStore.has = async function (key) {
|
|
3890
|
+
const val = await this.get(key);
|
|
3891
|
+
return val !== null && val !== undefined;
|
|
3892
|
+
};
|
|
3893
|
+
}
|
|
3878
3894
|
store = externalStore;
|
|
3879
3895
|
};
|
|
3880
3896
|
|
|
3897
|
+
async function storeHas(key) {
|
|
3898
|
+
if (!store) return false;
|
|
3899
|
+
if (typeof store.has === 'function') {
|
|
3900
|
+
return await store.has(key);
|
|
3901
|
+
}
|
|
3902
|
+
if (typeof store.get === 'function') {
|
|
3903
|
+
const val = await store.get(key);
|
|
3904
|
+
return val !== null && val !== undefined;
|
|
3905
|
+
}
|
|
3906
|
+
return false;
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3881
3909
|
/**
|
|
3882
3910
|
* Orchestrates request identification using a persistent anchor (cookie)
|
|
3883
3911
|
* and fingerprint verification.
|
|
@@ -3962,7 +3990,7 @@ async function resolveRequestIdentity(context, securityConfig = {}) {
|
|
|
3962
3990
|
* @param {object} deviceData - The device's activity data.
|
|
3963
3991
|
* @returns {Promise<{historyScore: number, rotationScore: number}>}
|
|
3964
3992
|
*/
|
|
3965
|
-
async function getBehavioralIndicators(context, deviceData) {
|
|
3993
|
+
async function getBehavioralIndicators(context, deviceData, netProfile = null) {
|
|
3966
3994
|
const rapidChangeThresholdMs = 2000; // 2 secondes
|
|
3967
3995
|
const maxRapidChanges = 3;
|
|
3968
3996
|
|
|
@@ -4040,13 +4068,18 @@ async function getBehavioralIndicators(context, deviceData) {
|
|
|
4040
4068
|
: MAX_DISTINCT_IPS_PER_DEVICE;
|
|
4041
4069
|
const freeIpChanges = isSharedIp ? 1 : 3;
|
|
4042
4070
|
|
|
4043
|
-
|
|
4071
|
+
let historyScore = Math.min(
|
|
4044
4072
|
100,
|
|
4045
4073
|
(Math.max(0, deviceData.ips.size - freeIpChanges) /
|
|
4046
4074
|
(maxIpsForDevice - freeIpChanges)) *
|
|
4047
4075
|
100,
|
|
4048
4076
|
);
|
|
4049
4077
|
|
|
4078
|
+
// Mobile CGNAT : désactivation/amortissement de la pénalité de rotation rapide d'IP
|
|
4079
|
+
if (netProfile?.toleranceRotation) {
|
|
4080
|
+
historyScore = Math.min(historyScore, 10.0);
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4050
4083
|
// Score based on rapid identity rotation (0-100)
|
|
4051
4084
|
const rotationScore = Math.min(
|
|
4052
4085
|
100,
|
|
@@ -4177,6 +4210,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
4177
4210
|
// Execute non-interdependent asynchronous operations in parallel
|
|
4178
4211
|
const zkpProof = (context.getHeader ? context.getHeader('x-zkp-proof') : null) || (context.headers ? context.headers['x-zkp-proof'] : null) || (context.query ? context.query['pow_zkp'] : null) || (context.queryParams ? context.queryParams['pow_zkp'] : null) || '';
|
|
4179
4212
|
const zkpY = zkpProof ? zkpProof.split(":")[0] : null;
|
|
4213
|
+
const netProfile = defaultAsnLookup.lookup(clientIp);
|
|
4180
4214
|
|
|
4181
4215
|
const [
|
|
4182
4216
|
behavioral,
|
|
@@ -4187,7 +4221,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
4187
4221
|
{ botnetClusterScore },
|
|
4188
4222
|
_ // store.set result
|
|
4189
4223
|
] = await Promise.all([
|
|
4190
|
-
getBehavioralIndicators(context, deviceData), // This modifies deviceData, so it must be done before saving deviceData
|
|
4224
|
+
getBehavioralIndicators(context, deviceData, netProfile), // This modifies deviceData, so it must be done before saving deviceData
|
|
4191
4225
|
getThreatIntelScore(context, zkpY, securityConfig), // NOUVEAU: Score de Threat Intelligence Fédéré
|
|
4192
4226
|
getTlsSpoofingScore(context),
|
|
4193
4227
|
getSubnetScore(context, deviceId, securityConfig),
|
|
@@ -4198,7 +4232,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
|
|
|
4198
4232
|
|
|
4199
4233
|
// Synchronous calculations
|
|
4200
4234
|
const { headerAnomalyScore } = getHeaderAnomalies(context);
|
|
4201
|
-
const similarityThreshold = securityConfig?.similarityThreshold ??
|
|
4235
|
+
const similarityThreshold = securityConfig?.similarityThreshold ?? netProfile.inflectionPoint;
|
|
4202
4236
|
const inconsistencyScore = calculateAnalogInconsistencyScore(consistencyScore, similarityThreshold);
|
|
4203
4237
|
|
|
4204
4238
|
const { behaviorScore } = getBehaviorScore(context); // Appel de la fonction
|
|
@@ -4690,7 +4724,7 @@ async function getThreatIntelScore(context, zkpY, threatIntelConfig = {}) {
|
|
|
4690
4724
|
|
|
4691
4725
|
// 1. Détection déterministe : Clé publique ZKP bannie par consensus fédéré
|
|
4692
4726
|
if (zkpY) {
|
|
4693
|
-
const isBanned = await
|
|
4727
|
+
const isBanned = await storeHas(`banned-zkp-y:${zkpY}`);
|
|
4694
4728
|
if (isBanned) {
|
|
4695
4729
|
score = 100.0;
|
|
4696
4730
|
signals.push({
|
|
@@ -5236,12 +5270,16 @@ export class FingerprintEngine {
|
|
|
5236
5270
|
|
|
5237
5271
|
const { clientIp = "unknown", path, cookies = {}, query = {}, isStatic, graphqlOperationType, graphqlOperationName } = requestContext;
|
|
5238
5272
|
|
|
5239
|
-
|
|
5273
|
+
const queryCoopOp = query.coop_op || (typeof requestContext.body === 'object' && requestContext.body ? requestContext.body.coop_op : null);
|
|
5274
|
+
if (queryCoopOp) {
|
|
5275
|
+
const bodyObj = typeof requestContext.body === 'object' && requestContext.body !== null ? requestContext.body : {};
|
|
5240
5276
|
const coopParams = {
|
|
5241
5277
|
...query,
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5278
|
+
...bodyObj,
|
|
5279
|
+
coop_op: queryCoopOp,
|
|
5280
|
+
signature: requestContext.headers?.['x-federation-signature'] || query.signature || bodyObj.signature,
|
|
5281
|
+
signature_ed25519: requestContext.headers?.['x-federation-signature-ed25519'] || query.signature_ed25519 || bodyObj.signature_ed25519,
|
|
5282
|
+
timestamp: requestContext.headers?.['x-federation-timestamp'] || query.timestamp || bodyObj.timestamp
|
|
5245
5283
|
};
|
|
5246
5284
|
const result = await handleCooperativeRequest(coopParams, clientIp, this.securityConfig);
|
|
5247
5285
|
return {
|
|
@@ -5352,33 +5390,38 @@ export class FingerprintEngine {
|
|
|
5352
5390
|
return { action: 'next', score: 0, vector: {} };
|
|
5353
5391
|
}
|
|
5354
5392
|
|
|
5355
|
-
// --- PRIVATE ACCESS TOKENS (PAT / RFC
|
|
5393
|
+
// --- PRIVATE ACCESS TOKENS (PAT / RFC 9578 & Privacy Pass) ZERO-FRICTION CHALLENGE EXEMPTION ---
|
|
5356
5394
|
const rawPatTokens = extractPrivateAccessTokens(requestContext.headers);
|
|
5395
|
+
let hasValidPat = false;
|
|
5357
5396
|
if (rawPatTokens.length > 0) {
|
|
5397
|
+
// Même avec un PAT valide, une attaque applicative avérée ou un terminal condamné doit être neutralisé
|
|
5398
|
+
if (deviceData?.condemned || this._hasCertainAttack(requestContext)) {
|
|
5399
|
+
return {
|
|
5400
|
+
action: 'block',
|
|
5401
|
+
status: 404,
|
|
5402
|
+
body: 'Forbidden',
|
|
5403
|
+
score: 100,
|
|
5404
|
+
vector: { honeypotScore: 100 }
|
|
5405
|
+
};
|
|
5406
|
+
}
|
|
5358
5407
|
const patConfig = this.securityConfig.pat || {};
|
|
5359
5408
|
for (const rawToken of rawPatTokens) {
|
|
5360
5409
|
const parsedToken = parsePrivateAccessToken(rawToken);
|
|
5361
5410
|
if (parsedToken) {
|
|
5362
5411
|
const nonceKey = `pat-nonce:${parsedToken.nonce}`;
|
|
5363
|
-
const isReplayed = await
|
|
5412
|
+
const isReplayed = await storeHas(nonceKey);
|
|
5364
5413
|
if (!isReplayed) {
|
|
5365
5414
|
const isValid = verifyPatSignature(parsedToken, patConfig);
|
|
5366
5415
|
if (isValid) {
|
|
5367
5416
|
// Anti-replay: cache nonce with 24-hour TTL
|
|
5368
5417
|
const nonceTtl = patConfig.nonceTtl || 86400;
|
|
5369
5418
|
await store.set(nonceKey, true, nonceTtl);
|
|
5370
|
-
this._log('Private Access Token (PAT) cryptographically verified -
|
|
5419
|
+
this._log('Private Access Token (PAT) cryptographically verified - challenge exemption enabled', {
|
|
5371
5420
|
tokenType: parsedToken.tokenType,
|
|
5372
5421
|
tokenKeyId: parsedToken.tokenKeyId
|
|
5373
5422
|
});
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
score: 0.0,
|
|
5377
|
-
vector: { pat_verified: 100.0, privacy_pass: 100.0 },
|
|
5378
|
-
intendedAction: 'next'
|
|
5379
|
-
};
|
|
5380
|
-
if (newCookie) decision.newCookieForResponse = newCookie;
|
|
5381
|
-
return decision;
|
|
5423
|
+
hasValidPat = true;
|
|
5424
|
+
break;
|
|
5382
5425
|
}
|
|
5383
5426
|
}
|
|
5384
5427
|
}
|
|
@@ -5394,6 +5437,41 @@ export class FingerprintEngine {
|
|
|
5394
5437
|
let whitelisted = false;
|
|
5395
5438
|
let whitelistType = '';
|
|
5396
5439
|
|
|
5440
|
+
// Auto-whitelisting des pairs fédérés
|
|
5441
|
+
const federatedPeers = this.securityConfig.federatedPeers || [];
|
|
5442
|
+
if (federatedPeers.length > 0) {
|
|
5443
|
+
for (const peerUrl of federatedPeers) {
|
|
5444
|
+
try {
|
|
5445
|
+
if (new URL(peerUrl).hostname === clientIp || peerUrl === clientIp) {
|
|
5446
|
+
whitelisted = true;
|
|
5447
|
+
whitelistType = 'federated_peer';
|
|
5448
|
+
break;
|
|
5449
|
+
}
|
|
5450
|
+
} catch (e) {
|
|
5451
|
+
if (peerUrl === clientIp) {
|
|
5452
|
+
whitelisted = true;
|
|
5453
|
+
whitelistType = 'federated_peer';
|
|
5454
|
+
break;
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
}
|
|
5459
|
+
|
|
5460
|
+
if (!whitelisted) {
|
|
5461
|
+
const subnet = getIpSubnet(clientIp);
|
|
5462
|
+
const ua = requestContext.headers?.['user-agent'] || '';
|
|
5463
|
+
if (await storeHas(`federated-whitelist:ip:${clientIp}`)) {
|
|
5464
|
+
whitelisted = true;
|
|
5465
|
+
whitelistType = 'federated_whitelist_ip';
|
|
5466
|
+
} else if (subnet && await storeHas(`federated-whitelist:subnet:${subnet}`)) {
|
|
5467
|
+
whitelisted = true;
|
|
5468
|
+
whitelistType = 'federated_whitelist_subnet';
|
|
5469
|
+
} else if (ua && await storeHas(`federated-whitelist:user_agent:${ua}`)) {
|
|
5470
|
+
whitelisted = true;
|
|
5471
|
+
whitelistType = 'federated_whitelist_user_agent';
|
|
5472
|
+
}
|
|
5473
|
+
}
|
|
5474
|
+
|
|
5397
5475
|
if (this._isIpInAllowlist(clientIp)) {
|
|
5398
5476
|
whitelisted = true;
|
|
5399
5477
|
whitelistType = 'allowlist';
|
|
@@ -5500,6 +5578,9 @@ export class FingerprintEngine {
|
|
|
5500
5578
|
// This is a strong signal of spoofing.
|
|
5501
5579
|
suspicionVector.tlsSpoofingScore = Math.max(suspicionVector.tlsSpoofingScore || 0, 95);
|
|
5502
5580
|
}
|
|
5581
|
+
if (hasValidPat) {
|
|
5582
|
+
suspicionVector.pat_verified = 100.0;
|
|
5583
|
+
}
|
|
5503
5584
|
let finalScore = preCalculatedScore !== null ? preCalculatedScore : this.calculateFinalScore(suspicionVector);
|
|
5504
5585
|
|
|
5505
5586
|
this._log('Suspicion vector calculated', {
|
|
@@ -5888,7 +5969,7 @@ export class FingerprintEngine {
|
|
|
5888
5969
|
|
|
5889
5970
|
const threshold = this.securityConfig.federationConsensusThreshold || 3;
|
|
5890
5971
|
if (reportedPeers.length >= threshold) {
|
|
5891
|
-
if (!(await
|
|
5972
|
+
if (!(await storeHas(`banned-zkp-y:${zkpY}`))) {
|
|
5892
5973
|
await store.set(`banned-zkp-y:${zkpY}`, true, 86400 * 30); // Banni pour 30 jours
|
|
5893
5974
|
}
|
|
5894
5975
|
}
|
|
@@ -5988,7 +6069,7 @@ export class FingerprintEngine {
|
|
|
5988
6069
|
// OU
|
|
5989
6070
|
// 2. La requête est très suspecte (dépasse le seuil 'high'), ce qui annule la validité du ticket actuel.
|
|
5990
6071
|
const zkpProof = requestContext.headers['x-zkp-proof'] || query.pow_zkp || '';
|
|
5991
|
-
const hasValidTicket = await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming, zkpProof);
|
|
6072
|
+
const hasValidTicket = (await isTicketValid(clientIp, powCookie, deviceId, currentDeviceHash, allowRoaming, zkpProof)) || hasValidPat;
|
|
5992
6073
|
// Correction : Pour éviter une boucle infinie de challenges (qui mène à l'erreur 429),
|
|
5993
6074
|
// on fait confiance au ticket valide tant qu'il n'a pas expiré.
|
|
5994
6075
|
const maxIndicatorsCount = Object.values(suspicionVector).filter(val => typeof val === 'number' && val >= 100).length;
|
|
@@ -6435,6 +6516,62 @@ export async function handleCooperativeRequest(params, clientIp = '127.0.0.1', c
|
|
|
6435
6516
|
return { status: 'synchronized', banned: false, reportsCount: reportedPeers.length };
|
|
6436
6517
|
}
|
|
6437
6518
|
|
|
6519
|
+
if (op === 'share_whitelist') {
|
|
6520
|
+
const peers = config.federatedPeers || [];
|
|
6521
|
+
if (peers.length > 0) {
|
|
6522
|
+
const allowedIPsAndHosts = peers.map(url => {
|
|
6523
|
+
try { return new URL(url).hostname; } catch(e) { return url; }
|
|
6524
|
+
});
|
|
6525
|
+
if (!allowedIPsAndHosts.includes(clientIp)) {
|
|
6526
|
+
return { error: 'Unauthorized federation sender IP' };
|
|
6527
|
+
}
|
|
6528
|
+
}
|
|
6529
|
+
const entry = params.entry;
|
|
6530
|
+
const entryType = params.entry_type || 'ip'; // ip, subnet, user_agent
|
|
6531
|
+
const sigEd25519 = params.signature_ed25519;
|
|
6532
|
+
const timestampStr = params.timestamp;
|
|
6533
|
+
const ttlStr = params.ttl || '86400';
|
|
6534
|
+
|
|
6535
|
+
if (!entry || !entry.trim() || !sigEd25519 || !timestampStr) {
|
|
6536
|
+
return { error: 'Missing required parameters for share_whitelist' };
|
|
6537
|
+
}
|
|
6538
|
+
|
|
6539
|
+
const trimmedEntry = entry.trim();
|
|
6540
|
+
if (trimmedEntry === '*' || trimmedEntry === '0.0.0.0/0' || trimmedEntry === '::/0') {
|
|
6541
|
+
return { error: 'Permissive wildcard entries are prohibited' };
|
|
6542
|
+
}
|
|
6543
|
+
|
|
6544
|
+
const timestamp = Number(timestampStr);
|
|
6545
|
+
if (isNaN(timestamp) || Math.abs(Date.now() - timestamp) > 300000) {
|
|
6546
|
+
return { error: 'Timestamp expired or clock skew too high' };
|
|
6547
|
+
}
|
|
6548
|
+
|
|
6549
|
+
const publicKey = config.ed25519_public_key || process.env.ED25519_PUBLIC_KEY;
|
|
6550
|
+
if (!publicKey) {
|
|
6551
|
+
return { error: 'Missing public key for asymmetric verification' };
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
const msg = `${timestamp}:whitelist:${entryType}:${trimmedEntry}`;
|
|
6555
|
+
try {
|
|
6556
|
+
const cleanKey = publicKey.replace(/\\n/g, '\n');
|
|
6557
|
+
const isVerified = crypto.verify(
|
|
6558
|
+
null,
|
|
6559
|
+
Buffer.from(msg, 'utf8'),
|
|
6560
|
+
{ key: cleanKey, format: 'pem', type: 'spki' },
|
|
6561
|
+
Buffer.from(sigEd25519, 'hex')
|
|
6562
|
+
);
|
|
6563
|
+
if (!isVerified) {
|
|
6564
|
+
return { error: 'Invalid Ed25519 signature for whitelist synchronization' };
|
|
6565
|
+
}
|
|
6566
|
+
} catch (e) {
|
|
6567
|
+
return { error: 'Asymmetric signature verification failed: ' + e.message };
|
|
6568
|
+
}
|
|
6569
|
+
|
|
6570
|
+
const ttl = Math.min(604800, parseInt(ttlStr, 10) || 86400);
|
|
6571
|
+
await store.set(`federated-whitelist:${entryType}:${trimmedEntry}`, true, ttl);
|
|
6572
|
+
return { status: 'whitelist_synchronized', entry: trimmedEntry, entryType, ttl };
|
|
6573
|
+
}
|
|
6574
|
+
|
|
6438
6575
|
const nodeId = params.node_id || '';
|
|
6439
6576
|
if (!nodeId) {
|
|
6440
6577
|
return { error: 'Missing node_id' };
|
|
@@ -6576,6 +6713,51 @@ export async function handleCooperativeRequest(params, clientIp = '127.0.0.1', c
|
|
|
6576
6713
|
return null;
|
|
6577
6714
|
}
|
|
6578
6715
|
|
|
6716
|
+
export async function broadcastWhitelistedEntry(entry, entryType = 'ip', ttl = 86400, config = {}) {
|
|
6717
|
+
const peers = config.federatedPeers || [];
|
|
6718
|
+
if (peers.length === 0 || !entry) return;
|
|
6719
|
+
|
|
6720
|
+
const trimmedEntry = entry.trim();
|
|
6721
|
+
if (trimmedEntry === '*' || trimmedEntry === '0.0.0.0/0' || trimmedEntry === '::/0') {
|
|
6722
|
+
throw new Error('Permissive wildcard entries are prohibited');
|
|
6723
|
+
}
|
|
6724
|
+
|
|
6725
|
+
const ed25519Key = process.env.ED25519_PRIVATE_KEY || config.ed25519_private_key;
|
|
6726
|
+
if (!ed25519Key) {
|
|
6727
|
+
throw new Error('ED25519_PRIVATE_KEY is required for federated whitelist broadcast');
|
|
6728
|
+
}
|
|
6729
|
+
|
|
6730
|
+
const ts = Date.now();
|
|
6731
|
+
const msg = `${ts}:whitelist:${entryType}:${trimmedEntry}`;
|
|
6732
|
+
const cleanKey = ed25519Key.replace(/\\n/g, '\n');
|
|
6733
|
+
const signature = crypto.sign(null, Buffer.from(msg, 'utf8'), {
|
|
6734
|
+
key: cleanKey,
|
|
6735
|
+
format: 'pem',
|
|
6736
|
+
type: 'pkcs8'
|
|
6737
|
+
}).toString('hex');
|
|
6738
|
+
|
|
6739
|
+
const boundedTtl = Math.min(604800, parseInt(ttl, 10) || 86400);
|
|
6740
|
+
|
|
6741
|
+
peers.forEach(peerUrl => {
|
|
6742
|
+
const url = `${peerUrl}?coop_op=share_whitelist`;
|
|
6743
|
+
fetch(url, {
|
|
6744
|
+
method: 'POST',
|
|
6745
|
+
headers: {
|
|
6746
|
+
'Content-Type': 'application/json',
|
|
6747
|
+
'X-Federation-Signature-Ed25519': signature,
|
|
6748
|
+
'X-Federation-Timestamp': String(ts)
|
|
6749
|
+
},
|
|
6750
|
+
body: JSON.stringify({
|
|
6751
|
+
entry: trimmedEntry,
|
|
6752
|
+
entry_type: entryType,
|
|
6753
|
+
ttl: boundedTtl,
|
|
6754
|
+
signature_ed25519: signature,
|
|
6755
|
+
timestamp: ts
|
|
6756
|
+
})
|
|
6757
|
+
}).catch(() => {});
|
|
6758
|
+
});
|
|
6759
|
+
}
|
|
6760
|
+
|
|
6579
6761
|
|
|
6580
6762
|
/** @type {Map<number, number>} Cache des TTL optimisés par score de suspicion (clés de 0 à 100 par pas de 10) */
|
|
6581
6763
|
let optimizedTtlCache = new Map();
|
|
@@ -7382,6 +7564,8 @@ export const powMiddleware = (securityConfig) => {
|
|
|
7382
7564
|
* This is a common pattern to allow mocking of ES module functions.
|
|
7383
7565
|
*/
|
|
7384
7566
|
export const __internal = {
|
|
7567
|
+
defaultAsnLookup,
|
|
7568
|
+
NetworkProfile,
|
|
7385
7569
|
get store() { return store; }, // Export the store for testing
|
|
7386
7570
|
checkChallengeRateLimit,
|
|
7387
7571
|
getDeviceHash,
|
|
@@ -7438,6 +7622,7 @@ export const __internal = {
|
|
|
7438
7622
|
findPeerInSubnet,
|
|
7439
7623
|
handleCooperativeRequest,
|
|
7440
7624
|
broadcastBannedZkp,
|
|
7625
|
+
broadcastWhitelistedEntry,
|
|
7441
7626
|
generateIssuerPemKeys,
|
|
7442
7627
|
getMetric,
|
|
7443
7628
|
incrementCounter,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { AsnLookupEngine, NetworkProfile } from '../asn-lookup.js';
|
|
3
|
+
import { calculateAnalogInconsistencyScore } from '../fingerprint.js';
|
|
4
|
+
|
|
5
|
+
describe('Native AsnLookupEngine Patricia Trie', () => {
|
|
6
|
+
const engine = new AsnLookupEngine();
|
|
7
|
+
|
|
8
|
+
it('should resolve Hetzner and AWS Datacenter IPs under 50ns with prior score 55.0', () => {
|
|
9
|
+
const hetznerProfile = engine.lookup('95.216.12.34');
|
|
10
|
+
expect(hetznerProfile.type).toBe('HOSTING');
|
|
11
|
+
expect(hetznerProfile.baseScore).toBe(55.0);
|
|
12
|
+
expect(hetznerProfile.inflectionPoint).toBe(0.85);
|
|
13
|
+
|
|
14
|
+
const awsProfile = engine.lookup('54.210.1.20');
|
|
15
|
+
expect(awsProfile.type).toBe('HOSTING');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should identify Mobile CGNAT 100.64.0.0/10 with rotation tolerance', () => {
|
|
19
|
+
const cgnatProfile = engine.lookup('100.70.1.25');
|
|
20
|
+
expect(cgnatProfile.type).toBe('CELLULAR');
|
|
21
|
+
expect(cgnatProfile.toleranceRotation).toBe(true);
|
|
22
|
+
expect(cgnatProfile.inflectionPoint).toBe(0.60);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should identify Starlink Satellite ranges with elevated jitter tolerance', () => {
|
|
26
|
+
const starlinkProfile = engine.lookup('98.97.10.5');
|
|
27
|
+
expect(starlinkProfile.type).toBe('SATELLITE');
|
|
28
|
+
expect(starlinkProfile.jitterTolerance).toBe(150.0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should default unknown residential IP to 0.0 base score', () => {
|
|
32
|
+
const resProfile = engine.lookup('82.120.45.67');
|
|
33
|
+
expect(resProfile.type).toBe('RESIDENTIAL');
|
|
34
|
+
expect(resProfile.baseScore).toBe(0.0);
|
|
35
|
+
expect(resProfile.inflectionPoint).toBe(0.72);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('Analog Inconsistency Modulation', () => {
|
|
39
|
+
it('should be significantly more sensitive on Datacenter IP than Mobile for identical consistency', () => {
|
|
40
|
+
const consistency = 0.80; // Légère incohérence de rendu (ex: Canvas altéré)
|
|
41
|
+
|
|
42
|
+
const datacenterProfile = NetworkProfile.HOSTING; // inflectionPoint = 0.85
|
|
43
|
+
const mobileProfile = NetworkProfile.CELLULAR; // inflectionPoint = 0.60
|
|
44
|
+
const residentialProfile = NetworkProfile.RESIDENTIAL; // inflectionPoint = 0.72
|
|
45
|
+
|
|
46
|
+
const scoreDatacenter = calculateAnalogInconsistencyScore(consistency, datacenterProfile);
|
|
47
|
+
const scoreResidential = calculateAnalogInconsistencyScore(consistency, residentialProfile);
|
|
48
|
+
const scoreMobile = calculateAnalogInconsistencyScore(consistency, mobileProfile);
|
|
49
|
+
|
|
50
|
+
// Sur Datacenter (0.80 < 0.85), la suspicion décolle immédiatement
|
|
51
|
+
expect(scoreDatacenter).toBeGreaterThan(55.0);
|
|
52
|
+
// Sur Mobile (0.80 > 0.60), la variation est entièrement absorbée
|
|
53
|
+
expect(scoreMobile).toBeLessThan(10.0);
|
|
54
|
+
expect(scoreDatacenter).toBeGreaterThan(scoreResidential);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint;
|
|
6
|
+
|
|
7
|
+
class AsnTrieNode
|
|
8
|
+
{
|
|
9
|
+
/** @var array<?AsnTrieNode> */
|
|
10
|
+
public array $children = [null, null];
|
|
11
|
+
public ?NetworkProfile $profile = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Patricia Trie / Radix Trie en mémoire vive pour résolution ultra-rapide (< 50ns)
|
|
16
|
+
* sans appel réseau bloquant.
|
|
17
|
+
*/
|
|
18
|
+
class AsnLookupEngine
|
|
19
|
+
{
|
|
20
|
+
private static ?self $instance = null;
|
|
21
|
+
private AsnTrieNode $rootV4;
|
|
22
|
+
private AsnTrieNode $rootV6;
|
|
23
|
+
|
|
24
|
+
public function __construct()
|
|
25
|
+
{
|
|
26
|
+
$this->rootV4 = new AsnTrieNode();
|
|
27
|
+
$this->rootV6 = new AsnTrieNode();
|
|
28
|
+
$this->loadDefaultPrefixes();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public static function getInstance(): self
|
|
32
|
+
{
|
|
33
|
+
if (self::$instance === null) {
|
|
34
|
+
self::$instance = new self();
|
|
35
|
+
}
|
|
36
|
+
return self::$instance;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public function insert(string $cidr, NetworkProfile $profile): void
|
|
40
|
+
{
|
|
41
|
+
$parts = explode('/', trim($cidr), 2);
|
|
42
|
+
$ip = $parts[0];
|
|
43
|
+
$isV4 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
|
44
|
+
$isV6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
|
45
|
+
if (!$isV4 && !$isV6) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$maxBits = $isV4 ? 32 : 128;
|
|
50
|
+
$prefixLen = isset($parts[1]) ? (int)$parts[1] : $maxBits;
|
|
51
|
+
if ($prefixLen < 0 || $prefixLen > $maxBits) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$bytes = inet_pton($ip);
|
|
56
|
+
if ($bytes === false) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$current = $isV4 ? $this->rootV4 : $this->rootV6;
|
|
61
|
+
for ($i = 0; $i < $prefixLen; $i++) {
|
|
62
|
+
$byteIdx = (int)($i / 8);
|
|
63
|
+
$bitIdx = 7 - ($i % 8);
|
|
64
|
+
$bit = (ord($bytes[$byteIdx]) >> $bitIdx) & 1;
|
|
65
|
+
|
|
66
|
+
if ($current->children[$bit] === null) {
|
|
67
|
+
$current->children[$bit] = new AsnTrieNode();
|
|
68
|
+
}
|
|
69
|
+
$current = $current->children[$bit];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
$current->profile = $profile;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public function lookup(?string $ip): NetworkProfile
|
|
76
|
+
{
|
|
77
|
+
if (empty($ip)) {
|
|
78
|
+
return NetworkProfile::residential();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
$cleanIp = trim($ip);
|
|
82
|
+
if (str_starts_with(strtolower($cleanIp), '::ffff:')) {
|
|
83
|
+
$cleanIp = substr($cleanIp, 7);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
$isV4 = filter_var($cleanIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
|
|
87
|
+
$isV6 = filter_var($cleanIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
|
88
|
+
if (!$isV4 && !$isV6) {
|
|
89
|
+
return NetworkProfile::residential();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$bytes = inet_pton($cleanIp);
|
|
93
|
+
if ($bytes === false) {
|
|
94
|
+
return NetworkProfile::residential();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
$current = $isV4 ? $this->rootV4 : $this->rootV6;
|
|
98
|
+
$matchedProfile = null;
|
|
99
|
+
$totalBits = $isV4 ? 32 : 128;
|
|
100
|
+
|
|
101
|
+
for ($i = 0; $i < $totalBits; $i++) {
|
|
102
|
+
if ($current->profile !== null) {
|
|
103
|
+
$matchedProfile = $current->profile; // Longest Prefix Match
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
$byteIdx = (int)($i / 8);
|
|
107
|
+
$bitIdx = 7 - ($i % 8);
|
|
108
|
+
$bit = (ord($bytes[$byteIdx]) >> $bitIdx) & 1;
|
|
109
|
+
|
|
110
|
+
$current = $current->children[$bit];
|
|
111
|
+
if ($current === null) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if ($current !== null && $current->profile !== null) {
|
|
117
|
+
$matchedProfile = $current->profile;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return $matchedProfile ?? NetworkProfile::residential();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public function loadDefaultPrefixes(): void
|
|
124
|
+
{
|
|
125
|
+
// Mobile CGNAT (RFC 6598)
|
|
126
|
+
$this->insert('100.64.0.0/10', NetworkProfile::cellular());
|
|
127
|
+
|
|
128
|
+
// Datacenters & Cloud majeurs (AWS, Hetzner, OVH, DigitalOcean)
|
|
129
|
+
$datacenterRanges = [
|
|
130
|
+
// AWS
|
|
131
|
+
'3.0.0.0/9', '3.128.0.0/9', '18.192.0.0/11', '34.192.0.0/10',
|
|
132
|
+
'35.156.0.0/14', '52.0.0.0/11', '54.0.0.0/8',
|
|
133
|
+
// Hetzner
|
|
134
|
+
'78.46.0.0/15', '88.198.0.0/16', '94.130.0.0/16', '95.216.0.0/15',
|
|
135
|
+
'116.202.0.0/15', '135.181.0.0/16', '136.243.0.0/16', '138.201.0.0/16',
|
|
136
|
+
'142.132.0.0/16', '144.76.0.0/16', '148.251.0.0/16', '159.69.0.0/16',
|
|
137
|
+
'168.119.0.0/16', '178.63.0.0/16', '188.40.0.0/16', '195.201.0.0/16',
|
|
138
|
+
// OVH
|
|
139
|
+
'51.68.0.0/14', '51.75.0.0/15', '51.77.0.0/16', '51.79.0.0/16',
|
|
140
|
+
'51.81.0.0/16', '51.83.0.0/16', '51.89.0.0/16', '51.91.0.0/16',
|
|
141
|
+
'137.74.0.0/16', '141.94.0.0/15', '145.239.0.0/16', '147.135.0.0/16',
|
|
142
|
+
'176.31.0.0/16', '178.32.0.0/15', '188.165.0.0/16', '198.27.64.0/18',
|
|
143
|
+
// DigitalOcean
|
|
144
|
+
'64.225.0.0/16', '68.183.0.0/16', '104.248.0.0/16', '128.199.0.0/16',
|
|
145
|
+
'134.209.0.0/16', '138.68.0.0/16', '138.197.0.0/16', '139.59.0.0/16',
|
|
146
|
+
'142.93.0.0/16', '143.198.0.0/16', '146.190.0.0/16', '157.230.0.0/16',
|
|
147
|
+
'159.65.0.0/16', '159.89.0.0/16', '161.35.0.0/16', '164.90.128.0/17',
|
|
148
|
+
'165.22.0.0/16', '165.227.0.0/16', '167.99.0.0/16', '174.138.0.0/16',
|
|
149
|
+
'178.62.0.0/16', '178.128.0.0/16', '188.166.0.0/16', '206.189.0.0/16',
|
|
150
|
+
];
|
|
151
|
+
foreach ($datacenterRanges as $range) {
|
|
152
|
+
$this->insert($range, NetworkProfile::hosting());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Satellite (Starlink Space-X)
|
|
156
|
+
$satelliteRanges = [
|
|
157
|
+
'98.97.0.0/16', '129.222.0.0/16', '143.130.0.0/16',
|
|
158
|
+
'143.244.0.0/16', '206.214.224.0/19'
|
|
159
|
+
];
|
|
160
|
+
foreach ($satelliteRanges as $range) {
|
|
161
|
+
$this->insert($range, NetworkProfile::satellite());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Tor Exit Relays / Proxies anonymiseurs
|
|
165
|
+
$anonymizerRanges = [
|
|
166
|
+
'185.220.101.0/24', '185.220.102.0/24', '185.220.103.0/24',
|
|
167
|
+
'185.100.86.128/25', '198.98.56.0/24', '199.249.230.0/24'
|
|
168
|
+
];
|
|
169
|
+
foreach ($anonymizerRanges as $range) {
|
|
170
|
+
$this->insert($range, NetworkProfile::anonymizer());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|