@anonympins/fingerprint 0.5.5 → 0.6.1
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 +21 -0
- package/README.md +7 -6
- package/package.json +1 -1
- package/public/fp.js +1 -1
- package/public/fp.wasm +0 -0
- package/src/js/build-client.js +1 -1
- package/src/js/fingerprint.client.js +19 -5
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +430 -74
- package/src/js/problem-manager.js +22 -0
- package/src/js/tests/cross_parity.test.js +17 -11
- package/src/js/tests/fingerprint.client.test.js +130 -128
- package/src/js/tests/fingerprint.test.js +17 -2
- package/src/js/ttl-optimization.worker.js +51 -0
- package/src/php/Challenge/ChallengeUtils.php +120 -8
- package/src/php/FingerprintBuilder.php +193 -185
- package/src/php/FingerprintEngine.php +245 -3
- package/src/php/ProblemManager.php +20 -0
- package/src/php/RequestContext.php +2 -0
- package/src/php/Tests/ChallengeUtilsTest.php +446 -285
- package/src/php/Utils/RequestUtils.php +26 -1
|
@@ -1,186 +1,194 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
declare(strict_types=1);
|
|
4
|
-
|
|
5
|
-
namespace Anonympins\Fingerprint;
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Classe pour construire une empreinte composite (Multi-Hash).
|
|
9
|
-
* Format de sortie : "grp1:hash1|grp2:hash2|grp3:hash3"
|
|
10
|
-
*/
|
|
11
|
-
class FingerprintBuilder
|
|
12
|
-
{
|
|
13
|
-
/**
|
|
14
|
-
* @var array<string, string|int>
|
|
15
|
-
*/
|
|
16
|
-
private array $components = [];
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Ajoute un composant à l'empreinte.
|
|
20
|
-
* La valeur est hachée pour l'anonymiser et réduire sa taille.
|
|
21
|
-
*
|
|
22
|
-
* @param string $group Le nom du groupe (ex: 'hw', 'screen', 'geo').
|
|
23
|
-
* @param string|int|bool|null $value La valeur brute à hacher.
|
|
24
|
-
* @return self
|
|
25
|
-
*/
|
|
26
|
-
public function add(string $group, $value): self
|
|
27
|
-
{
|
|
28
|
-
if ($value === null || $value === '') {
|
|
29
|
-
return $this;
|
|
30
|
-
}
|
|
31
|
-
// On hache la valeur individuellement.
|
|
32
|
-
$this->components[$group] = self::cyrb53((string)$value);
|
|
33
|
-
return $this;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Ajoute un composant brut sans le hacher.
|
|
38
|
-
* Utile pour les métriques qui doivent être lues telles quelles par le serveur.
|
|
39
|
-
*
|
|
40
|
-
* @param string $group Le nom du groupe.
|
|
41
|
-
* @param string|int|null $value La valeur brute.
|
|
42
|
-
* @return self
|
|
43
|
-
*/
|
|
44
|
-
public function addRaw(string $group, $value): self
|
|
45
|
-
{
|
|
46
|
-
if ($value === null) {
|
|
47
|
-
return $this;
|
|
48
|
-
}
|
|
49
|
-
$this->components[$group] = $value;
|
|
50
|
-
return $this;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Génère la chaîne de l'empreinte finale.
|
|
55
|
-
* Les composants sont triés par clé pour garantir un ordre déterministe.
|
|
56
|
-
*
|
|
57
|
-
* @return string
|
|
58
|
-
*/
|
|
59
|
-
public function __toString(): string
|
|
60
|
-
{
|
|
61
|
-
// ksort trie le tableau par clé.
|
|
62
|
-
ksort($this->components);
|
|
63
|
-
|
|
64
|
-
$parts = [];
|
|
65
|
-
foreach ($this->components as $key => $hash) {
|
|
66
|
-
$parts[] = "{$key}:{$hash}";
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return implode('|', $parts);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Compare deux empreintes et retourne un score de similarité (de 0 à 1).
|
|
74
|
-
* Utilise une pondération pour donner plus d'importance aux invariants forts (Canvas, GPU, JA3).
|
|
75
|
-
*
|
|
76
|
-
* @param string|null $fpString1 Empreinte A.
|
|
77
|
-
* @param string|null $fpString2 Empreinte B.
|
|
78
|
-
* @return float
|
|
79
|
-
*/
|
|
80
|
-
public static function compare(?string $fpString1, ?string $fpString2): float
|
|
81
|
-
{
|
|
82
|
-
if (empty($fpString1) || empty($fpString2)) {
|
|
83
|
-
return 0.0;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
$parse = function (string $str): array {
|
|
87
|
-
$map = [];
|
|
88
|
-
foreach (explode('|', $str) as $part) {
|
|
89
|
-
$pair = explode(':', $part, 2);
|
|
90
|
-
if (count($pair) === 2 && !empty($pair[0]) && !empty($pair[1])) {
|
|
91
|
-
$map[$pair[0]] = $pair[1];
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return $map;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
$map1 = $parse($fpString1);
|
|
98
|
-
$map2 = $parse($fpString2);
|
|
99
|
-
|
|
100
|
-
$volatileKeys = [
|
|
101
|
-
'ch_ua', 'ch_platform', 'ch_mobile', 'ch_model', 'ch_arch', 'ch_bitness',
|
|
102
|
-
'cookie_keys', 'upgrade', 'network', 'http_ver',
|
|
103
|
-
'x_forwarded_for', 'x_real_ip', 'cf_connecting_ip'
|
|
104
|
-
];
|
|
105
|
-
|
|
106
|
-
$weights = [
|
|
107
|
-
'cvs' => 5.0, 'gpu' => 4.0, 'ja3' => 3.5, 'ja4' => 4.0, 'ja4s' => 4.0, 'ja4h' => 3.8,
|
|
108
|
-
'h2_settings' => 3.0, 'tcp_fp' => 2.5, 'ua' => 2.0,
|
|
109
|
-
'client_fp_hash' => 3.0, 'browser' => 1.5, 'os_version' => 1.5,
|
|
110
|
-
'device_type' => 1.0, 'hw' => 1.5, 'scr' => 1.0, 'os' => 0.8, 'geo' => 0.5,
|
|
111
|
-
];
|
|
112
|
-
|
|
113
|
-
$weightedMatches = 0.0;
|
|
114
|
-
$totalWeight = 0.0;
|
|
115
|
-
|
|
116
|
-
$allKeys = array_unique(array_merge(array_keys($map1), array_keys($map2)));
|
|
117
|
-
|
|
118
|
-
foreach ($allKeys as $key) {
|
|
119
|
-
// On ignore les clés volatiles pour cette comparaison spécifique.
|
|
120
|
-
if (in_array($key, $volatileKeys, true)) { // @phpstan-ignore-line
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// On ne compare que les clés qui ont un poids défini.
|
|
125
|
-
$weight = $weights[$key] ?? null;
|
|
126
|
-
if ($weight === null) continue;
|
|
127
|
-
|
|
128
|
-
$totalWeight += $weight;
|
|
129
|
-
if (isset($map1[$key]) && isset($map2[$key])) {
|
|
130
|
-
if ($map1[$key] === $map2[$key]) {
|
|
131
|
-
$weightedMatches += $weight;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return $totalWeight === 0.0 ? 0.0 : $weightedMatches / $totalWeight;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Algorithme de hachage cyrb53 (rapide et faible taux de collision).
|
|
141
|
-
* Porté depuis la version JavaScript.
|
|
142
|
-
*
|
|
143
|
-
* @param string $str La chaîne à hacher.
|
|
144
|
-
* @param int $seed Une graine optionnelle.
|
|
145
|
-
* @return string Le hash sous forme de chaîne de caractères.
|
|
146
|
-
*/
|
|
147
|
-
public static function cyrb53(string $str, int $seed = 0): string
|
|
148
|
-
{
|
|
149
|
-
$h1 = 0xdeadbeef ^ $seed;
|
|
150
|
-
$h2 = 0x41c6ce57 ^ $seed;
|
|
151
|
-
|
|
152
|
-
for ($i = 0, $l = strlen($str); $i < $l; $i++) {
|
|
153
|
-
$ch = ord($str[$i]);
|
|
154
|
-
$h1 = self::imul($h1 ^ $ch, 2654435761);
|
|
155
|
-
$h2 = self::imul($h2 ^ $ch, 1597334677);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
$h1 = self::imul($h1 ^ ($h1 >> 16), 2246822507) ^ self::imul($h2 ^ ($h2 >> 13), 3266489909);
|
|
159
|
-
$h2 = self::imul($h2 ^ ($h2 >> 16), 2246822507) ^ self::imul($h1 ^ ($h1 >> 13), 3266489909);
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Anonympins\Fingerprint;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Classe pour construire une empreinte composite (Multi-Hash).
|
|
9
|
+
* Format de sortie : "grp1:hash1|grp2:hash2|grp3:hash3"
|
|
10
|
+
*/
|
|
11
|
+
class FingerprintBuilder
|
|
12
|
+
{
|
|
13
|
+
/**
|
|
14
|
+
* @var array<string, string|int>
|
|
15
|
+
*/
|
|
16
|
+
private array $components = [];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ajoute un composant à l'empreinte.
|
|
20
|
+
* La valeur est hachée pour l'anonymiser et réduire sa taille.
|
|
21
|
+
*
|
|
22
|
+
* @param string $group Le nom du groupe (ex: 'hw', 'screen', 'geo').
|
|
23
|
+
* @param string|int|bool|null $value La valeur brute à hacher.
|
|
24
|
+
* @return self
|
|
25
|
+
*/
|
|
26
|
+
public function add(string $group, $value): self
|
|
27
|
+
{
|
|
28
|
+
if ($value === null || $value === '') {
|
|
29
|
+
return $this;
|
|
30
|
+
}
|
|
31
|
+
// On hache la valeur individuellement.
|
|
32
|
+
$this->components[$group] = self::cyrb53((string)$value);
|
|
33
|
+
return $this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ajoute un composant brut sans le hacher.
|
|
38
|
+
* Utile pour les métriques qui doivent être lues telles quelles par le serveur.
|
|
39
|
+
*
|
|
40
|
+
* @param string $group Le nom du groupe.
|
|
41
|
+
* @param string|int|null $value La valeur brute.
|
|
42
|
+
* @return self
|
|
43
|
+
*/
|
|
44
|
+
public function addRaw(string $group, $value): self
|
|
45
|
+
{
|
|
46
|
+
if ($value === null) {
|
|
47
|
+
return $this;
|
|
48
|
+
}
|
|
49
|
+
$this->components[$group] = $value;
|
|
50
|
+
return $this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Génère la chaîne de l'empreinte finale.
|
|
55
|
+
* Les composants sont triés par clé pour garantir un ordre déterministe.
|
|
56
|
+
*
|
|
57
|
+
* @return string
|
|
58
|
+
*/
|
|
59
|
+
public function __toString(): string
|
|
60
|
+
{
|
|
61
|
+
// ksort trie le tableau par clé.
|
|
62
|
+
ksort($this->components);
|
|
63
|
+
|
|
64
|
+
$parts = [];
|
|
65
|
+
foreach ($this->components as $key => $hash) {
|
|
66
|
+
$parts[] = "{$key}:{$hash}";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return implode('|', $parts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compare deux empreintes et retourne un score de similarité (de 0 à 1).
|
|
74
|
+
* Utilise une pondération pour donner plus d'importance aux invariants forts (Canvas, GPU, JA3).
|
|
75
|
+
*
|
|
76
|
+
* @param string|null $fpString1 Empreinte A.
|
|
77
|
+
* @param string|null $fpString2 Empreinte B.
|
|
78
|
+
* @return float
|
|
79
|
+
*/
|
|
80
|
+
public static function compare(?string $fpString1, ?string $fpString2): float
|
|
81
|
+
{
|
|
82
|
+
if (empty($fpString1) || empty($fpString2)) {
|
|
83
|
+
return 0.0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
$parse = function (string $str): array {
|
|
87
|
+
$map = [];
|
|
88
|
+
foreach (explode('|', $str) as $part) {
|
|
89
|
+
$pair = explode(':', $part, 2);
|
|
90
|
+
if (count($pair) === 2 && !empty($pair[0]) && !empty($pair[1])) {
|
|
91
|
+
$map[$pair[0]] = $pair[1];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return $map;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
$map1 = $parse($fpString1);
|
|
98
|
+
$map2 = $parse($fpString2);
|
|
99
|
+
|
|
100
|
+
$volatileKeys = [
|
|
101
|
+
'ch_ua', 'ch_platform', 'ch_mobile', 'ch_model', 'ch_arch', 'ch_bitness',
|
|
102
|
+
'cookie_keys', 'upgrade', 'network', 'http_ver',
|
|
103
|
+
'x_forwarded_for', 'x_real_ip', 'cf_connecting_ip'
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
$weights = [
|
|
107
|
+
'cvs' => 5.0, 'gpu' => 4.0, 'ja3' => 3.5, 'ja4' => 4.0, 'ja4s' => 4.0, 'ja4h' => 3.8,
|
|
108
|
+
'h2_settings' => 3.0, 'tcp_fp' => 2.5, 'ua' => 2.0,
|
|
109
|
+
'client_fp_hash' => 3.0, 'browser' => 1.5, 'os_version' => 1.5,
|
|
110
|
+
'device_type' => 1.0, 'hw' => 1.5, 'scr' => 1.0, 'os' => 0.8, 'geo' => 0.5,
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
$weightedMatches = 0.0;
|
|
114
|
+
$totalWeight = 0.0;
|
|
115
|
+
|
|
116
|
+
$allKeys = array_unique(array_merge(array_keys($map1), array_keys($map2)));
|
|
117
|
+
|
|
118
|
+
foreach ($allKeys as $key) {
|
|
119
|
+
// On ignore les clés volatiles pour cette comparaison spécifique.
|
|
120
|
+
if (in_array($key, $volatileKeys, true)) { // @phpstan-ignore-line
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// On ne compare que les clés qui ont un poids défini.
|
|
125
|
+
$weight = $weights[$key] ?? null;
|
|
126
|
+
if ($weight === null) continue;
|
|
127
|
+
|
|
128
|
+
$totalWeight += $weight;
|
|
129
|
+
if (isset($map1[$key]) && isset($map2[$key])) {
|
|
130
|
+
if ($map1[$key] === $map2[$key]) {
|
|
131
|
+
$weightedMatches += $weight;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return $totalWeight === 0.0 ? 0.0 : $weightedMatches / $totalWeight;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Algorithme de hachage cyrb53 (rapide et faible taux de collision).
|
|
141
|
+
* Porté depuis la version JavaScript.
|
|
142
|
+
*
|
|
143
|
+
* @param string $str La chaîne à hacher.
|
|
144
|
+
* @param int $seed Une graine optionnelle.
|
|
145
|
+
* @return string Le hash sous forme de chaîne de caractères.
|
|
146
|
+
*/
|
|
147
|
+
public static function cyrb53(string $str, int $seed = 0): string
|
|
148
|
+
{
|
|
149
|
+
$h1 = 0xdeadbeef ^ $seed;
|
|
150
|
+
$h2 = 0x41c6ce57 ^ $seed;
|
|
151
|
+
|
|
152
|
+
for ($i = 0, $l = strlen($str); $i < $l; $i++) {
|
|
153
|
+
$ch = ord($str[$i]);
|
|
154
|
+
$h1 = self::imul($h1 ^ $ch, 2654435761);
|
|
155
|
+
$h2 = self::imul($h2 ^ $ch, 1597334677);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
$h1 = self::imul($h1 ^ ($h1 >> 16), 2246822507) ^ self::imul($h2 ^ ($h2 >> 13), 3266489909);
|
|
159
|
+
$h2 = self::imul($h2 ^ ($h2 >> 16), 2246822507) ^ self::imul($h1 ^ ($h1 >> 13), 3266489909);
|
|
160
|
+
|
|
161
|
+
// Sur les architectures 64 bits (standard en production), PHP gère nativement les entiers 64 bits signés.
|
|
162
|
+
// Nous pouvons éviter bcmath en effectuant des décalages binaires natifs pour un gain drastique de performances.
|
|
163
|
+
if (PHP_INT_SIZE === 8) {
|
|
164
|
+
$h1_u = $h1 & 0xffffffff;
|
|
165
|
+
$h2_u = $h2 & 0xffffffff;
|
|
166
|
+
$val_h2 = ((2097151 & $h2_u) << 32) | $h1_u;
|
|
167
|
+
return (string)$val_h2;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Fallback bcmath uniquement sur l'architecture obsolète 32 bits.
|
|
171
|
+
$val_h2 = bcadd(bcmul((string)(2097151 & $h2), '4294967296'), (string)($h1 >= 0 ? $h1 : $h1 + 4294967296));
|
|
172
|
+
return $val_h2;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Émule la multiplication 32-bit `Math.imul` de JavaScript.
|
|
177
|
+
*
|
|
178
|
+
* @param int $a
|
|
179
|
+
* @param int $b
|
|
180
|
+
* @return int Un entier signé 32-bit.
|
|
181
|
+
*/
|
|
182
|
+
private static function imul(int $a, int $b): int
|
|
183
|
+
{
|
|
184
|
+
// Emulation of JavaScript's Math.imul for signed 32-bit integer multiplication.
|
|
185
|
+
// This version correctly handles overflows on 64-bit systems.
|
|
186
|
+
$ah = ($a >> 16) & 0xffff;
|
|
187
|
+
$al = $a & 0xffff;
|
|
188
|
+
$bh = ($b >> 16) & 0xffff;
|
|
189
|
+
$bl = $b & 0xffff;
|
|
190
|
+
$lo = $al * $bl;
|
|
191
|
+
$hi = (($lo >> 16) + ($al * $bh) + ($ah * $bl)) & 0xffff;
|
|
192
|
+
return (($hi << 16) | ($lo & 0xffff)) | 0;
|
|
193
|
+
}
|
|
186
194
|
}
|
|
@@ -596,6 +596,7 @@
|
|
|
596
596
|
if ((time() * 1000) - ($deviceData['lastUpdate'] ?? 0) > 10 * 60 * 1000) { // 10 minutes
|
|
597
597
|
$deviceData['ips'] = [];
|
|
598
598
|
$deviceData['rapidChangeCount'] = 0;
|
|
599
|
+
$deviceData['ipTimes'] = [];
|
|
599
600
|
}
|
|
600
601
|
$deviceData['lastUpdate'] = time() * 1000;
|
|
601
602
|
|
|
@@ -661,13 +662,13 @@
|
|
|
661
662
|
}
|
|
662
663
|
|
|
663
664
|
// Check 4: La stagnation (Lack of Entropy / Genericity)
|
|
664
|
-
if ($uaParts['browser']) {
|
|
665
|
+
if (is_string($uaParts['browser'])) {
|
|
665
666
|
$ja4Key = "ja4-browsers:{$ja4}";
|
|
666
667
|
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
667
668
|
if (!is_array($seenBrowsers)) {
|
|
668
669
|
$seenBrowsers = [];
|
|
669
670
|
}
|
|
670
|
-
$browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
|
|
671
|
+
$browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
|
|
671
672
|
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
672
673
|
$seenBrowsers[] = $browserFamily;
|
|
673
674
|
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
@@ -873,7 +874,7 @@
|
|
|
873
874
|
|
|
874
875
|
$coopOp = $context->query['coop_op'] ?? null;
|
|
875
876
|
if ($coopOp) {
|
|
876
|
-
$result = ChallengeUtils::handleCooperativeRequest($context->query);
|
|
877
|
+
$result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
|
|
877
878
|
return [
|
|
878
879
|
'action' => 'challenge',
|
|
879
880
|
'status' => 200,
|
|
@@ -1124,6 +1125,24 @@
|
|
|
1124
1125
|
MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
|
|
1125
1126
|
$this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1126
1127
|
}
|
|
1128
|
+
|
|
1129
|
+
// Federated Threat Intelligence & ZKP Synchronization
|
|
1130
|
+
$zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
|
|
1131
|
+
$parts = explode(':', $zkpProof);
|
|
1132
|
+
if (count($parts) === 3) {
|
|
1133
|
+
$zkpY = $parts[0];
|
|
1134
|
+
$zkpT = $parts[1];
|
|
1135
|
+
$zkpS = $parts[2];
|
|
1136
|
+
// 1. Valider cryptographiquement la preuve avant de bannir/diffuser
|
|
1137
|
+
if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
|
|
1138
|
+
// 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
|
|
1139
|
+
if (!$store->has("banned-zkp-y:{$zkpY}")) {
|
|
1140
|
+
$store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
|
|
1141
|
+
$this->broadcastBannedZkp($zkpY);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1127
1146
|
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
|
|
1128
1147
|
if ($this->dryRun) {
|
|
1129
1148
|
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
@@ -1406,6 +1425,84 @@
|
|
|
1406
1425
|
return false;
|
|
1407
1426
|
}
|
|
1408
1427
|
|
|
1428
|
+
|
|
1429
|
+
private function broadcastBannedZkp(string $zkpY): void
|
|
1430
|
+
{
|
|
1431
|
+
$peers = $this->securityConfig['federatedPeers'] ?? [];
|
|
1432
|
+
if (empty($peers)) return;
|
|
1433
|
+
|
|
1434
|
+
$timestamp = (int)(microtime(true) * 1000);
|
|
1435
|
+
$msg = "{$timestamp}:{$zkpY}";
|
|
1436
|
+
|
|
1437
|
+
$signature = '';
|
|
1438
|
+
$isAsymmetric = false;
|
|
1439
|
+
|
|
1440
|
+
$privateKey = $_ENV['ED25519_PRIVATE_KEY'] ?? getenv('ED25519_PRIVATE_KEY');
|
|
1441
|
+
if ($privateKey) {
|
|
1442
|
+
try {
|
|
1443
|
+
$cleanKey = str_replace('\n', "\n", $privateKey);
|
|
1444
|
+
$pkeyObj = openssl_pkey_get_private($cleanKey);
|
|
1445
|
+
if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
|
|
1446
|
+
$signature = bin2hex($sigBytes);
|
|
1447
|
+
$isAsymmetric = true;
|
|
1448
|
+
}
|
|
1449
|
+
} catch (\Throwable $e) {
|
|
1450
|
+
error_log('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
if (!$isAsymmetric) {
|
|
1455
|
+
$secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
|
|
1456
|
+
$signature = hash_hmac('sha256', $msg, $secret);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
foreach ($peers as $peerUrl) {
|
|
1460
|
+
$this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
|
|
1461
|
+
'zkpY' => $zkpY,
|
|
1462
|
+
'signature' => $isAsymmetric ? '' : $signature,
|
|
1463
|
+
'signature_ed25519' => $isAsymmetric ? $signature : '',
|
|
1464
|
+
'timestamp' => $timestamp
|
|
1465
|
+
]);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private function asyncPost(string $url, array $params): void
|
|
1470
|
+
{
|
|
1471
|
+
$parts = parse_url($url);
|
|
1472
|
+
if ($parts === false) return;
|
|
1473
|
+
|
|
1474
|
+
$host = $parts['host'];
|
|
1475
|
+
$port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
|
|
1476
|
+
$path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
|
|
1477
|
+
$scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
|
|
1478
|
+
|
|
1479
|
+
$postData = json_encode($params);
|
|
1480
|
+
|
|
1481
|
+
$fp = @stream_socket_client(
|
|
1482
|
+
"{$scheme}{$host}:{$port}",
|
|
1483
|
+
$errno,
|
|
1484
|
+
$errstr,
|
|
1485
|
+
0.5,
|
|
1486
|
+
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
if ($fp) {
|
|
1490
|
+
stream_set_blocking($fp, false);
|
|
1491
|
+
$out = "POST {$path} HTTP/1.1\r\n";
|
|
1492
|
+
$out .= "Host: {$host}\r\n";
|
|
1493
|
+
$out .= "Content-Type: application/json\r\n";
|
|
1494
|
+
$out .= "Content-Length: " . strlen($postData) . "\r\n";
|
|
1495
|
+
$out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
|
|
1496
|
+
$out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
|
|
1497
|
+
$out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
|
|
1498
|
+
$out .= "Connection: Close\r\n\r\n";
|
|
1499
|
+
$out .= $postData;
|
|
1500
|
+
|
|
1501
|
+
@fwrite($fp, $out);
|
|
1502
|
+
@fclose($fp);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1409
1506
|
/**
|
|
1410
1507
|
* Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
|
|
1411
1508
|
* en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
|
|
@@ -1509,4 +1606,149 @@
|
|
|
1509
1606
|
$problemManager = ProblemManager::getInstance();
|
|
1510
1607
|
return $problemManager->getProblems();
|
|
1511
1608
|
}
|
|
1609
|
+
private static function decodeCBOR(string $str, int &$offset = 0)
|
|
1610
|
+
{
|
|
1611
|
+
if ($offset >= strlen($str)) throw new \Exception("End of CBOR");
|
|
1612
|
+
$initial = ord($str[$offset++]);
|
|
1613
|
+
$major = $initial >> 5;
|
|
1614
|
+
$val = $initial & 0x1f;
|
|
1615
|
+
|
|
1616
|
+
$readInt = function (int $val, string $str, int &$offset) {
|
|
1617
|
+
if ($val < 24) return $val;
|
|
1618
|
+
if ($val === 24) return ord($str[$offset++]);
|
|
1619
|
+
if ($val === 25) {
|
|
1620
|
+
$b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
|
|
1621
|
+
return ($b1 << 8) | $b2;
|
|
1622
|
+
}
|
|
1623
|
+
if ($val === 26) {
|
|
1624
|
+
$b1 = ord($str[$offset++]); $b2 = ord($str[$offset++]);
|
|
1625
|
+
$b3 = ord($str[$offset++]); $b4 = ord($str[$offset++]);
|
|
1626
|
+
return ($b1 << 24) | ($b2 << 16) | ($b3 << 8) | $b4;
|
|
1627
|
+
}
|
|
1628
|
+
throw new \Exception("Unsupported integer size: " . $val);
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
if ($major === 0) {
|
|
1632
|
+
return $readInt($val, $str, $offset);
|
|
1633
|
+
} elseif ($major === 1) {
|
|
1634
|
+
return -1 - $readInt($val, $str, $offset);
|
|
1635
|
+
} elseif ($major === 2 || $major === 3) {
|
|
1636
|
+
$len = $readInt($val, $str, $offset);
|
|
1637
|
+
$bytes = substr($str, $offset, $len);
|
|
1638
|
+
$offset += $len;
|
|
1639
|
+
return $bytes;
|
|
1640
|
+
} elseif ($major === 4) {
|
|
1641
|
+
$len = $readInt($val, $str, $offset);
|
|
1642
|
+
$arr = [];
|
|
1643
|
+
for ($i = 0; $i < $len; $i++) {
|
|
1644
|
+
$arr[] = self::decodeCBOR($str, $offset);
|
|
1645
|
+
}
|
|
1646
|
+
return $arr;
|
|
1647
|
+
} elseif ($major === 5) {
|
|
1648
|
+
$len = $readInt($val, $str, $offset);
|
|
1649
|
+
$map = [];
|
|
1650
|
+
for ($i = 0; $i < $len; $i++) {
|
|
1651
|
+
$k = self::decodeCBOR($str, $offset);
|
|
1652
|
+
$v = self::decodeCBOR($str, $offset);
|
|
1653
|
+
$map[$k] = $v;
|
|
1654
|
+
}
|
|
1655
|
+
return $map;
|
|
1656
|
+
}
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
private static function getTrustedHardwareRoots(): array
|
|
1661
|
+
{
|
|
1662
|
+
return [
|
|
1663
|
+
"-----BEGIN CERTIFICATE-----\n" .
|
|
1664
|
+
"MIIDHzCCAfegAwIBAgIJANCvWjvF+2O6MA0GCSqGSIb3DQEBCwUAMC0xKzApBgNV\n" .
|
|
1665
|
+
"BAMTIll1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMB4XDTE0MDgwNDAwMDAwMFox\n" .
|
|
1666
|
+
"TSUxSDBGBgNVBAMMT1l1YmljbyBBdHRlc3RhdGlvbiBSb290IENBMSowKAYDVQQK\n" .
|
|
1667
|
+
"EyFZdWJpY28gQUIxDzANBgNVBAcTBVN0b2NraG9sbTELMAkGA1UEBhMCU0UwggEi\n" .
|
|
1668
|
+
"MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6XW0d87g+N6kGgSgC/H9UfA2p\n" .
|
|
1669
|
+
"-----END CERTIFICATE-----",
|
|
1670
|
+
"-----BEGIN CERTIFICATE-----\n" .
|
|
1671
|
+
"MIIB1DCCAXWgAwIBAgIEUI70WjAKBggqhkjOPQQDAjArMSkwJwYDVQQDEyBGSURP\n" .
|
|
1672
|
+
"IEFsbGlhbmNlIFJvb3QgQ0EgKFRlc3QpMB4XDTE0MDgxODA4MzA0NVoXDTM5MDgx\n" .
|
|
1673
|
+
"ODA4MzA0NVowKzEpMCcGA1UEAxMgRklETyBBbGxpYW5jZSBSb290IENBIChUZXN0\n" .
|
|
1674
|
+
"KTB2MBAGByqGSM49AgEGBSuBBAAiA2IABFv81Jm9M7AehfOIdpCH567gP0yqS40m\n" +
|
|
1675
|
+
"aN0j1a8n152G7n/nUf7J0j9F4pL9J2w1X8hN1N8f9Y3G9w8L29/m7/zX3O3n2e7/\n" .
|
|
1676
|
+
"g==\n" .
|
|
1677
|
+
"-----END CERTIFICATE-----"
|
|
1678
|
+
];
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
private function verifyWebAuthnHardwareAnchor(array $anchor, array &$deviceData): bool
|
|
1682
|
+
{
|
|
1683
|
+
if (empty($anchor['type'])) return false;
|
|
1684
|
+
|
|
1685
|
+
try {
|
|
1686
|
+
$clientDataHash = hash('sha256', base64_decode($anchor['clientDataJSON']), true);
|
|
1687
|
+
|
|
1688
|
+
if ($anchor['type'] === 'registration') {
|
|
1689
|
+
if (empty($anchor['publicKey']) || empty($anchor['credentialId']) || empty($anchor['attestationObject'])) {
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
$attestationBytes = base64_decode($anchor['attestationObject']);
|
|
1694
|
+
$offset = 0;
|
|
1695
|
+
$decoded = self::decodeCBOR($attestationBytes, $offset);
|
|
1696
|
+
|
|
1697
|
+
if (!$decoded || empty($decoded['fmt']) || empty($decoded['attStmt'])) {
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
$fmt = $decoded['fmt'];
|
|
1702
|
+
$attStmt = $decoded['attStmt'];
|
|
1703
|
+
|
|
1704
|
+
if ($fmt !== 'none') {
|
|
1705
|
+
if (empty($attStmt['x5c']) || !is_array($attStmt['x5c'])) {
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
$pems = [];
|
|
1710
|
+
foreach ($attStmt['x5c'] as $der) {
|
|
1711
|
+
$pems[] = "-----BEGIN CERTIFICATE-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END CERTIFICATE-----";
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
for ($i = 0; $i < count($pems) - 1; $i++) {
|
|
1715
|
+
if (openssl_x509_verify($pems[$i], $pems[$i + 1]) !== 1) {
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
$rootPem = $pems[count($pems) - 1];
|
|
1721
|
+
$trusted = false;
|
|
1722
|
+
$trustedRoots = self::getTrustedHardwareRoots();
|
|
1723
|
+
foreach ($trustedRoots as $trustedRootPem) {
|
|
1724
|
+
if (openssl_x509_verify($rootPem, $trustedRootPem) === 1 || md5($rootPem) === md5($trustedRootPem)) {
|
|
1725
|
+
$trusted = true;
|
|
1726
|
+
break;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
if (!$trusted) {
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
$deviceData['webauthnPublicKey'] = $anchor['publicKey'];
|
|
1735
|
+
$deviceData['webauthnCredentialId'] = $anchor['credentialId'];
|
|
1736
|
+
return true;
|
|
1737
|
+
} elseif ($anchor['type'] === 'assertion') {
|
|
1738
|
+
$storedPublicKeyPem = $deviceData['webauthnPublicKey'] ?? null;
|
|
1739
|
+
if (!$storedPublicKeyPem || ($deviceData['webauthnCredentialId'] ?? '') !== $anchor['credentialId']) {
|
|
1740
|
+
return false;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
$verifyBuffer = base64_decode($anchor['authenticatorData']) . $clientDataHash;
|
|
1744
|
+
$publicKey = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----\n" . chunk_split($storedPublicKeyPem, 64, "\n") . "-----END PUBLIC KEY-----");
|
|
1745
|
+
if (!$publicKey) return false;
|
|
1746
|
+
|
|
1747
|
+
return openssl_verify($verifyBuffer, base64_decode($anchor['signature']), $publicKey, OPENSSL_ALGO_SHA256) === 1;
|
|
1748
|
+
}
|
|
1749
|
+
} catch (\Throwable $e) {
|
|
1750
|
+
error_log('[WebAuthn-Server] PHP verification failed: ' . $e->getMessage());
|
|
1751
|
+
}
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1512
1754
|
}
|