@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.
@@ -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} [inflectionPoint=0.72] Point d'inflexion où la suspicion accélère.
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, inflectionPoint = 0.72, steepness = 12) {
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(steepness * (s - inflectionPoint)));
3451
- const minVal = 1.0 / (1.0 + Math.exp(steepness * (1.0 - inflectionPoint)));
3452
- const maxVal = 1.0 / (1.0 + Math.exp(steepness * (0.0 - inflectionPoint)));
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 0;
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(0, data.score - decay);
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
- const historyScore = Math.min(
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 ?? 0.72;
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 store.has(`banned-zkp-y:${zkpY}`);
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
- if (query.coop_op) {
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
- signature: requestContext.headers?.['x-federation-signature'] || query.signature,
5243
- signature_ed25519: requestContext.headers?.['x-federation-signature-ed25519'] || query.signature_ed25519,
5244
- timestamp: requestContext.headers?.['x-federation-timestamp'] || query.timestamp
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 9505 & Privacy Pass) ZERO-FRICTION BYPASS ---
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 store.has(nonceKey);
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 - granting zero-friction bypass', {
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
- const decision = {
5375
- action: 'next',
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 store.has(`banned-zkp-y:${zkpY}`))) {
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
+ }