@anonympins/fingerprint 0.3.4 → 0.3.5

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.
@@ -0,0 +1,228 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint;
6
+
7
+ /**
8
+ * Classe de détection d'anomalies et d'usurpation TLS (JA3) en PHP.
9
+ */
10
+ class Ja3AnomalyDetector
11
+ {
12
+ // Liste décimale des valeurs GREASE (RFC 8701) utilisées par les moteurs Chromium/Safari récents
13
+ private const GREASE_VALUES = [
14
+ 2570, 6682, 10794, 14906, 19018, 23130, 27242, 31354,
15
+ 35466, 39578, 43690, 47802, 51914, 55926, 60038, 64150
16
+ ];
17
+
18
+ // Base de données locale de signatures JA3 MD5 connues pour la corroboration de base
19
+ private const TLS_FINGERPRINT_DB = [
20
+ 'e188a442b87f422c5a1e80b05399435b' => ['Chrome'],
21
+ 'd8e35855049321c6042a4325c697858f' => ['Chrome'],
22
+ 'a9f90958d44533748c139a5d1895b925' => ['Chrome'],
23
+ '3b5379916d2b3882253c42885956a350' => ['Chrome'],
24
+ '59822058c95c33d2d06e52f410855c8c' => ['Chrome'],
25
+ 'b386946a5a586163c7c533636b45c355' => ['Firefox'],
26
+ '66236495a523c1785f8f3a105b248b11' => ['Firefox'],
27
+ 'b73d470006575b5e35167a0b5a8540e2' => ['Firefox'],
28
+ '8443d7562933834333943465d52363cf' => ['Firefox'],
29
+ 'b633f21d532d35967c8753c38536b4d3' => ['Safari'],
30
+ '4d7a28d5f55b359b69100a311013f03e' => ['Safari', 'Chrome', 'Firefox'],
31
+ '8dd3d7532873575314df23c447543001' => ['Safari', 'Chrome', 'Firefox'],
32
+ // Bibliothèques et scrapers automatisés connus
33
+ '47344a349b75c4e82333475553b5f358' => ['Python'],
34
+ 'b29587b8a143c42546133ad7704b3310' => ['Go'],
35
+ 'd435b5223b2884c5a832b842637e245f' => ['Java'],
36
+ 'c72366b9551263d990b7fa574225332c' => ['curl'],
37
+ ];
38
+
39
+ /**
40
+ * Analyse une chaîne JA3 brute non hachée.
41
+ * Format attendu : "TLSVersion,Ciphers,Extensions,EllipticCurves,EllipticCurveFormats"
42
+ */
43
+ public static function parseJa3(string $ja3String): ?array
44
+ {
45
+ if (empty($ja3String)) {
46
+ return null;
47
+ }
48
+
49
+ $parts = explode(',', $ja3String);
50
+ if (count($parts) !== 5) {
51
+ return null;
52
+ }
53
+
54
+ return [
55
+ 'tlsVersion' => (int)$parts[0],
56
+ 'ciphers' => $parts[1] !== '' ? array_map('intval', explode('-', $parts[1])) : [],
57
+ 'extensions' => $parts[2] !== '' ? array_map('intval', explode('-', $parts[2])) : [],
58
+ 'curves' => $parts[3] !== '' ? array_map('intval', explode('-', $parts[3])) : [],
59
+ 'points' => $parts[4] !== '' ? array_map('intval', explode('-', $parts[4])) : []
60
+ ];
61
+ }
62
+
63
+ /**
64
+ * Vérifie si un tableau contient au moins une valeur GREASE.
65
+ */
66
+ public static function hasGrease(array $values): bool
67
+ {
68
+ foreach ($values as $val) {
69
+ if (in_array($val, self::GREASE_VALUES, true)) {
70
+ return true;
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Parse sommairement le User-Agent pour en extraire la famille de navigateur.
78
+ */
79
+ public static function getBrowserFamily(string $userAgent): ?string
80
+ {
81
+ $ua = strtolower($userAgent);
82
+ if (strpos($ua, 'edg') !== false) {
83
+ return 'Edge';
84
+ }
85
+ if (strpos($ua, 'chrome') !== false) {
86
+ return 'Chrome';
87
+ }
88
+ if (strpos($ua, 'firefox') !== false) {
89
+ return 'Firefox';
90
+ }
91
+ if (strpos($ua, 'safari') !== false) {
92
+ return 'Safari';
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Calcule le score global d'anomalie et d'usurpation JA3.
99
+ *
100
+ * @param string|null $ja3Hash L'empreinte MD5 du JA3 (32 caractères)
101
+ * @param string|null $ja3Raw L'empreinte brute non hachée (si disponible)
102
+ * @param string $userAgent Le User-Agent de la requête
103
+ * @param string $httpVersion La version HTTP de la requête (ex: "HTTP/2", "HTTP/1.1", ou "2.0")
104
+ * @param object|null $cacheInstance Un driver de cache (ex: instance Redis) supportant get() et set() pour la détection de stagnation
105
+ * @return int Un score de suspicion compris entre 0 et 100
106
+ */
107
+ public static function getJa3AnomalyScore(
108
+ ?string $ja3Hash,
109
+ ?string $ja3Raw,
110
+ string $userAgent,
111
+ string $httpVersion,
112
+ ?object $cacheInstance = null
113
+ ): int {
114
+ $score = 0;
115
+ $claimedBrowser = self::getBrowserFamily($userAgent);
116
+ $isHumanBrowser = in_array($claimedBrowser, ['Chrome', 'Firefox', 'Safari', 'Edge'], true);
117
+
118
+ // --- ANALYSE 1 : CONTRÔLE SUR LE MD5 DU JA3 ---
119
+ if ($ja3Hash && strlen($ja3Hash) === 32) {
120
+ if (isset(self::TLS_FINGERPRINT_DB[$ja3Hash])) {
121
+ $expectedBrowsers = self::TLS_FINGERPRINT_DB[$ja3Hash];
122
+
123
+ // Cas A : L'empreinte correspond à un outil de scraping mais le UA prétend être humain
124
+ $isLibrary = array_intersect($expectedBrowsers, ['Python', 'Go', 'Java', 'curl']);
125
+ if (!empty($isLibrary) && $isHumanBrowser) {
126
+ $score = max($score, 90); // Suspicion maximale : usurpation évidente
127
+ }
128
+
129
+ // Cas B : Incohérence directe entre le UA prétendu et la stack TLS correspondante
130
+ if ($claimedBrowser !== null) {
131
+ $matched = false;
132
+ foreach ($expectedBrowsers as $expected) {
133
+ if (stripos($claimedBrowser, $expected) === 0) {
134
+ $matched = true;
135
+ break;
136
+ }
137
+ }
138
+ if (!$matched) {
139
+ $score = max($score, 80); // Le navigateur déclaré ne correspond pas au client TLS utilisé
140
+ }
141
+ }
142
+ }
143
+
144
+ // Cas C : Tracking de stagnation multi-UA (Stateful)
145
+ if ($cacheInstance && $claimedBrowser !== null && method_exists($cacheInstance, 'get') && method_exists($cacheInstance, 'set')) {
146
+ $cacheKey = "ja3-browsers:" . $ja3Hash;
147
+
148
+ try {
149
+ $rawCached = $cacheInstance->get($cacheKey);
150
+ $seenBrowsers = $rawCached ? json_decode((string)$rawCached, true) : [];
151
+ if (!is_array($seenBrowsers)) {
152
+ $seenBrowsers = [];
153
+ }
154
+
155
+ if (!in_array($claimedBrowser, $seenBrowsers, true)) {
156
+ $seenBrowsers[] = $claimedBrowser;
157
+ // Cache pendant 24 heures (86400 secondes)
158
+ if (method_exists($cacheInstance, 'setex')) {
159
+ $cacheInstance->setex($cacheKey, 86400, json_encode($seenBrowsers));
160
+ } else {
161
+ $cacheInstance->set($cacheKey, json_encode($seenBrowsers), 86400);
162
+ }
163
+ }
164
+
165
+ // Si une seule stack TLS génère des requêtes avec différents navigateurs, c'est un bot en rotation de UA
166
+ if (count($seenBrowsers) > 1) {
167
+ $score = max($score, 85);
168
+ }
169
+ } catch (\Throwable $e) {
170
+ // Tolérance aux pannes du cache
171
+ }
172
+ }
173
+ }
174
+
175
+ // --- ANALYSE 2 : CONTRÔLE PROFOND SUR L'EMPREINTE BRUTE (RAW JA3) ---
176
+ if ($ja3Raw) {
177
+ $parsed = self::parseJa3($ja3Raw);
178
+ if ($parsed) {
179
+ // Contrôle A : Mécanisme GREASE pour Chrome / Edge (obligatoire)
180
+ if ($claimedBrowser === 'Chrome' || $claimedBrowser === 'Edge') {
181
+ $hasCiphersGrease = self::hasGrease($parsed['ciphers']);
182
+ $hasExtensionsGrease = self::hasGrease($parsed['extensions']);
183
+
184
+ if (!$hasCiphersGrease && !$hasExtensionsGrease) {
185
+ // Chrome ou Edge moderne sans GREASE = spoofing de bas niveau (ex: python-requests déguisé)
186
+ $score = max($score, 75);
187
+ }
188
+ }
189
+
190
+ // Contrôle B : HTTP/2 ou HTTP/3 sans négociation ALPN (Extension 16)
191
+ $isH2OrHigher = (
192
+ strpos($httpVersion, '2.0') !== false ||
193
+ strpos($httpVersion, 'HTTP/2') !== false ||
194
+ strpos($httpVersion, 'HTTP/3') !== false
195
+ );
196
+ $hasAlpnExtension = in_array(16, $parsed['extensions'], true);
197
+
198
+ if ($isH2OrHigher && !$hasAlpnExtension) {
199
+ // Négociation HTTP/2 active au niveau serveur mais absente au niveau des extensions TLS du client
200
+ $score = max($score, 70);
201
+ }
202
+
203
+ // Contrôle C : Version TLS obsolète négociée par un navigateur moderne (ex: TLS < 1.2, id < 771)
204
+ if ($isHumanBrowser && $parsed['tlsVersion'] < 771) {
205
+ $score = max($score, 80);
206
+ }
207
+ }
208
+ }
209
+
210
+ return $score;
211
+ }
212
+ }
213
+
214
+ // --- EXEMPLE D'UTILISATION PRATIQUE ---
215
+ /*
216
+ $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
217
+ $httpVersion = $_SERVER['SERVER_PROTOCOL'] ?? '';
218
+
219
+ // Récupération des en-têtes injectés par votre Reverse-Proxy (Nginx, HAProxy, etc.)
220
+ $ja3Hash = $_SERVER['HTTP_X_JA3_HASH'] ?? null;
221
+ $ja3Raw = $_SERVER['HTTP_X_JA3_RAW'] ?? null;
222
+
223
+ // Redis facultatif pour la détection stateful de rotation UA
224
+ $redis = new \Redis();
225
+ $redis->connect('127.0.0.1', 6379);
226
+
227
+ $suspicionScore = Ja3AnomalyDetector::getJa3AnomalyScore($ja3Hash, $ja3Raw, $userAgent, $httpVersion, $redis);
228
+ */
@@ -0,0 +1,180 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Anonympins\Fingerprint\Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Anonympins\Fingerprint\Ja3AnomalyDetector;
9
+
10
+ /**
11
+ * Tests unitaires pour la classe Ja3AnomalyDetector.
12
+ */
13
+ class Ja3AnomalyDetectorTest extends TestCase
14
+ {
15
+ /**
16
+ * Teste le parsing d'une chaîne JA3 brute valide.
17
+ */
18
+ public function testParseJa3ValidString(): void
19
+ {
20
+ $ja3 = '771,4865-4866-4867,0-23-65281-10-11,29-23-24,0';
21
+ $parsed = Ja3AnomalyDetector::parseJa3($ja3);
22
+
23
+ $this->assertNotNull($parsed);
24
+ $this->assertSame(771, $parsed['tlsVersion']);
25
+ $this->assertSame([4865, 4866, 4867], $parsed['ciphers']);
26
+ $this->assertSame([0, 23, 65281, 10, 11], $parsed['extensions']);
27
+ $this->assertSame([29, 23, 24], $parsed['curves']);
28
+ $this->assertSame([0], $parsed['points']);
29
+ }
30
+
31
+ /**
32
+ * Teste le parsing avec des valeurs manquantes ou malformées.
33
+ */
34
+ public function testParseJa3InvalidString(): void
35
+ {
36
+ $this->assertNull(Ja3AnomalyDetector::parseJa3(''));
37
+ $this->assertNull(Ja3AnomalyDetector::parseJa3('771,4865,0-23')); // Moins de 5 parties
38
+ }
39
+
40
+ /**
41
+ * Teste la détection du mécanisme GREASE.
42
+ */
43
+ public function testHasGrease(): void
44
+ {
45
+ // Contient la valeur GREASE 2570 (0x0A0A)
46
+ $this->assertTrue(Ja3AnomalyDetector::hasGrease([4865, 2570, 4866]));
47
+ // Ne contient pas de valeur GREASE
48
+ $this->assertFalse(Ja3AnomalyDetector::hasGrease([4865, 4866, 4867]));
49
+ }
50
+
51
+ /**
52
+ * Teste l'extraction de la famille du User-Agent.
53
+ */
54
+ public function testGetBrowserFamily(): void
55
+ {
56
+ $this->assertSame('Chrome', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'));
57
+ $this->assertSame('Edge', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'));
58
+ $this->assertSame('Firefox', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0'));
59
+ $this->assertSame('Safari', Ja3AnomalyDetector::getBrowserFamily('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'));
60
+ $this->assertNull(Ja3AnomalyDetector::getBrowserFamily('curl/7.68.0'));
61
+ }
62
+
63
+ /**
64
+ * Teste la détection d'usurpation d'une bibliothèque connue (ex: Python/Go/curl).
65
+ */
66
+ public function testSpoofingLibraryDetected(): void
67
+ {
68
+ $pythonJa3 = '47344a349b75c4e82333475553b5f358'; // Signature Python dans DB
69
+ $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
70
+
71
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore($pythonJa3, null, $userAgent, 'HTTP/2');
72
+
73
+ // Devrait retourner un score élevé (90) car une signature python se fait passer pour Chrome
74
+ $this->assertEquals(90, $score);
75
+ }
76
+
77
+ /**
78
+ * Teste la détection d'une incohérence de navigateur (ex: signature Firefox avec UA Chrome).
79
+ */
80
+ public function testBrowserMismatchDetected(): void
81
+ {
82
+ $firefoxJa3 = 'b386946a5a586163c7c533636b45c355'; // Signature Firefox dans DB
83
+ $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
84
+
85
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore($firefoxJa3, null, $userAgent, 'HTTP/2');
86
+
87
+ $this->assertEquals(80, $score);
88
+ }
89
+
90
+ /**
91
+ * Teste la détection de stagnation de hash avec rotation d'User-Agents.
92
+ */
93
+ public function testStagnationWithRotatingUserAgents(): void
94
+ {
95
+ // Mock d'un stockage de cache minimal en mémoire
96
+ $cache = new class {
97
+ private array $store = [];
98
+ public function get(string $key): ?string { return $this->store[$key] ?? null; }
99
+ public function set(string $key, string $val, int $ttl = 0): void { $this->store[$key] = $val; }
100
+ public function setex(string $key, int $ttl, string $val): void { $this->store[$key] = $val; }
101
+ };
102
+
103
+ $unknownJa3 = '00000000000000000000000000000000';
104
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
105
+ $firefoxUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/120.0';
106
+
107
+ // Premier appel avec Chrome -> pas d'anomalie de stagnation encore
108
+ $score1 = Ja3AnomalyDetector::getJa3AnomalyScore($unknownJa3, null, $chromeUA, 'HTTP/2', $cache);
109
+ $this->assertLessThan(85, $score1);
110
+
111
+ // Deuxième appel avec la même stack TLS mais un UA Firefox -> Détection de rotation !
112
+ $score2 = Ja3AnomalyDetector::getJa3AnomalyScore($unknownJa3, null, $firefoxUA, 'HTTP/2', $cache);
113
+ $this->assertEquals(85, $score2);
114
+ }
115
+
116
+ /**
117
+ * Teste l'absence de valeurs GREASE pour un navigateur Chromium.
118
+ */
119
+ public function testMissingGreaseForChrome(): void
120
+ {
121
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
122
+ // JA3 brut valide mais sans AUCUNE valeur GREASE dans les extensions ou les ciphers
123
+ $ja3RawWithoutGrease = '771,4865-4866,0-23-10,29,0';
124
+
125
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithoutGrease, $chromeUA, 'HTTP/2');
126
+ $this->assertEquals(75, $score);
127
+ }
128
+
129
+ /**
130
+ * Teste le cas légitime d'un navigateur Chromium disposant de GREASE.
131
+ */
132
+ public function testLegitimateChromeWithGrease(): void
133
+ {
134
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
135
+ // 2570 est une valeur GREASE valide
136
+ $ja3RawWithGrease = '771,4865-2570,0-23-10,29,0';
137
+
138
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithGrease, $chromeUA, 'HTTP/2');
139
+ $this->assertLessThan(75, $score);
140
+ }
141
+
142
+ /**
143
+ * Teste la détection d'absence d'ALPN lors de connexions HTTP/2.
144
+ */
145
+ public function testHttp2WithoutAlpnExtension(): void
146
+ {
147
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
148
+ // Pas d'extension 16 (ALPN) dans les extensions
149
+ $ja3RawNoAlpn = '771,4865-2570,0-23-10,29,0';
150
+
151
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawNoAlpn, $chromeUA, 'HTTP/2');
152
+ $this->assertEquals(70, $score);
153
+ }
154
+
155
+ /**
156
+ * Teste la présence d'ALPN lors de connexions HTTP/2 (cas normal).
157
+ */
158
+ public function testHttp2WithAlpnExtension(): void
159
+ {
160
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
161
+ // L'extension 16 est présente
162
+ $ja3RawWithAlpn = '771,4865-2570,0-23-10-16,29,0';
163
+
164
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $ja3RawWithAlpn, $chromeUA, 'HTTP/2');
165
+ $this->assertLessThan(70, $score);
166
+ }
167
+
168
+ /**
169
+ * Teste l'utilisation d'une version obsolète de TLS par un navigateur récent.
170
+ */
171
+ public function testObsoleteTlsVersionWithModernBrowser(): void
172
+ {
173
+ $chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
174
+ // Version TLS 769 (TLS 1.0) initiée par un Chrome récent
175
+ $obsoleteTlsJa3 = '769,4865-2570,0-23-10-16,29,0';
176
+
177
+ $score = Ja3AnomalyDetector::getJa3AnomalyScore(null, $obsoleteTlsJa3, $chromeUA, 'HTTP/2');
178
+ $this->assertEquals(80, $score);
179
+ }
180
+ }
@@ -186,7 +186,7 @@ class RequestUtils
186
186
  public static function getHeaderAnomalies(RequestContext $context): array
187
187
  {
188
188
  $anomalyScore = 0;
189
- $ua = $context->getHeader('user-agent');
189
+ $ua = $context->getHeader('user-agent') ?? '';
190
190
  if (empty($ua) || strlen($ua) < 10) {
191
191
  $anomalyScore += 60;
192
192
  }
@@ -197,6 +197,17 @@ class RequestUtils
197
197
  $anomalyScore += 15;
198
198
  }
199
199
 
200
+ // TE: trailers check for Firefox on Desktop
201
+ $uaParts = self::parseUserAgent($ua);
202
+ $isFirefoxDesktop = isset($uaParts['browser']) && str_starts_with($uaParts['browser'], 'Firefox') && ($uaParts['device'] ?? 'desktop') === 'desktop';
203
+ $te = strtolower($context->getHeader('te') ?? '');
204
+
205
+ if ($isFirefoxDesktop && $te !== 'trailers') {
206
+ $anomalyScore += 30;
207
+ } elseif (!$isFirefoxDesktop && ($uaParts['device'] ?? 'desktop') === 'desktop' && $te === 'trailers') {
208
+ $anomalyScore += 30;
209
+ }
210
+
200
211
  return ['headerAnomalyScore' => min(100.0, $anomalyScore)];
201
212
  }
202
213
 
@@ -520,7 +531,7 @@ class RequestUtils
520
531
  $deviceData['lastChangeTimestamp'] = $now;
521
532
  }
522
533
  $deviceData['lastFpHash'] = $currentFpHash;
523
-
534
+
524
535
  // Enregistrement de l'IP
525
536
  if (!in_array($clientIp, $deviceData['ips'])) {
526
537
  $deviceData['ips'][] = $clientIp;
@@ -749,7 +760,7 @@ class RequestUtils
749
760
  * Parse une chaîne de requête GraphQL pour extraire le type et le nom de l'opération.
750
761
  * @param array<string, mixed> $body Le corps de la requête.
751
762
  * @return array{type: string, name: string}|null
752
- */
763
+ */
753
764
  public static function parseGraphQLQuery(array $body): ?array
754
765
  {
755
766
  $query = $body['query'] ?? null;
@@ -841,7 +852,7 @@ class RequestUtils
841
852
  if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
842
853
  $ipBinary = inet_pton($ip);
843
854
  if ($ipBinary === false) return null;
844
-
855
+
845
856
  $mask = self::generateMask($ipv4Prefix, 4);
846
857
  if ($mask === null) return null;
847
858
 
@@ -850,7 +861,7 @@ class RequestUtils
850
861
  } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
851
862
  $ipBinary = inet_pton($ip);
852
863
  if ($ipBinary === false) return null;
853
-
864
+
854
865
  $mask = self::generateMask($ipv6Prefix, 16);
855
866
  if ($mask === null) return null;
856
867