@anonympins/fingerprint 0.6.0 → 0.6.3
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 +28 -0
- package/README.md +128 -119
- package/composer.json +2 -1
- 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 +2 -0
- package/src/js/fingerprint.client.obfuscated.js +1 -1
- package/src/js/fingerprint.js +6802 -6411
- package/src/js/library.js +18 -11
- package/src/js/problem-manager.js +6 -2
- package/src/js/tests/cross_parity.test.js +17 -11
- package/src/js/tests/fingerprint.test.js +47 -10
- package/src/js/tests/metrics.test.js +157 -103
- package/src/php/Challenge/ChallengeUtils.php +69 -6
- package/src/php/Config/SecurityProfiles.php +3 -2
- package/src/php/FingerprintEngine.php +154 -21
- package/src/php/Optimization/OptimizationOperators.php +21 -21
- package/src/php/RequestContext.php +5 -0
- package/src/php/Tests/ChallengeUtilsTest.php +6 -4
- package/src/php/Tests/FingerprintEngineTest.php +186 -0
- package/src/php/Tests/MetricsTest.php +97 -45
- package/src/php/Tests/RequestUtilsTest.php +14 -14
- package/src/php/Tests/config/ed25519_key.json +4 -0
- package/src/php/Utils/Env.php +50 -0
- package/src/php/Utils/MetricsManager.php +209 -166
- package/src/php/Utils/RequestUtils.php +70 -14
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
use Anonympins\Fingerprint\Utils\Logger;
|
|
12
12
|
use Anonympins\Fingerprint\Utils\MetricsManager;
|
|
13
13
|
use Anonympins\Fingerprint\Utils\RequestUtils;
|
|
14
|
+
use Anonympins\Fingerprint\Utils\Env;
|
|
14
15
|
|
|
15
16
|
// Correction de l'import
|
|
16
17
|
|
|
@@ -164,14 +165,56 @@
|
|
|
164
165
|
|
|
165
166
|
public function __construct(array $securityConfig)
|
|
166
167
|
{
|
|
167
|
-
$this->isProduction = (
|
|
168
|
+
$this->isProduction = Env::get('APP_ENV') === 'production';
|
|
168
169
|
|
|
169
170
|
// Dynamically bind Ed25519 keys if passed via config
|
|
170
171
|
if (isset($securityConfig['ed25519_private_key'])) {
|
|
171
|
-
|
|
172
|
+
Env::set('ED25519_PRIVATE_KEY', $securityConfig['ed25519_private_key']);
|
|
172
173
|
}
|
|
173
174
|
if (isset($securityConfig['ed25519_public_key'])) {
|
|
174
|
-
|
|
175
|
+
Env::set('ED25519_PUBLIC_KEY', $securityConfig['ed25519_public_key']);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Génération et persistance automatique de la paire de clés Ed25519
|
|
179
|
+
$useAsymmetric = ($securityConfig['useAsymmetricTickets'] ?? false) === true || ($securityConfig['ed25519'] ?? '') === 'auto';
|
|
180
|
+
$envPrivate = Env::get('ED25519_PRIVATE_KEY');
|
|
181
|
+
if ($useAsymmetric && ($envPrivate === null || $envPrivate === '')) {
|
|
182
|
+
$configDir = dirname(__DIR__, 1) . '/config';
|
|
183
|
+
$persistentKeyPath = $configDir . '/ed25519_key.json';
|
|
184
|
+
|
|
185
|
+
if (file_exists($persistentKeyPath)) {
|
|
186
|
+
try {
|
|
187
|
+
$keys = json_decode(file_get_contents($persistentKeyPath), true);
|
|
188
|
+
if (isset($keys['privateKey'], $keys['publicKey'])) {
|
|
189
|
+
Env::set('ED25519_PRIVATE_KEY', $keys['privateKey']);
|
|
190
|
+
Env::set('ED25519_PUBLIC_KEY', $keys['publicKey']);
|
|
191
|
+
}
|
|
192
|
+
} catch (\Throwable $e) {
|
|
193
|
+
error_log('[Fingerprint] Failed to load persistent Ed25519 keys: ' . $e->getMessage());
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
try {
|
|
197
|
+
if (defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
198
|
+
$pkey = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_ED25519]);
|
|
199
|
+
if ($pkey && openssl_pkey_export($pkey, $privateKeyPem)) {
|
|
200
|
+
$details = openssl_pkey_get_details($pkey); // @phpstan-ignore-line
|
|
201
|
+
$publicKeyPem = $details['key']; // @phpstan-ignore-line
|
|
202
|
+
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
203
|
+
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
204
|
+
// Also update $_ENV and $_SERVER for consistency
|
|
205
|
+
if (!is_dir($configDir)) {
|
|
206
|
+
mkdir($configDir, 0777, true);
|
|
207
|
+
}
|
|
208
|
+
file_put_contents($persistentKeyPath, json_encode([
|
|
209
|
+
'privateKey' => $privateKeyPem,
|
|
210
|
+
'publicKey' => $publicKeyPem
|
|
211
|
+
], JSON_PRETTY_PRINT));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (\Throwable $e) {
|
|
215
|
+
error_log('[Fingerprint] Native Ed25519 key generation failed: ' . $e->getMessage());
|
|
216
|
+
}
|
|
217
|
+
}
|
|
175
218
|
}
|
|
176
219
|
|
|
177
220
|
// Auto-load optimized config if autotuning savePath is specified
|
|
@@ -222,7 +265,8 @@
|
|
|
222
265
|
'honeypot', 'threatIntel', 'whitelist', 'isStaticResource', 'isApiRequest', 'logger', 'probationaryTtl',
|
|
223
266
|
'autotuning', 'enableUsefulWork', 'usefulWorkConfigPath', 'challengeNewDevices', 'graphql_operation_allowlist', 'dryRun',
|
|
224
267
|
'similarityThreshold', 'summary', 'description',
|
|
225
|
-
'ed25519_private_key', 'ed25519_public_key'
|
|
268
|
+
'ed25519_private_key', 'ed25519_public_key',
|
|
269
|
+
'wasm', 'enableProofOfSpace', 'pospace', 'federatedPeers', 'federationSecret'
|
|
226
270
|
];
|
|
227
271
|
|
|
228
272
|
if (empty($config['weights'])) {
|
|
@@ -483,6 +527,9 @@
|
|
|
483
527
|
*/
|
|
484
528
|
private function resolveRequestIdentity(RequestContext $context, array &$suspicionVector): array
|
|
485
529
|
{
|
|
530
|
+
if ($context->resolvedIdentity !== null) {
|
|
531
|
+
return $context->resolvedIdentity;
|
|
532
|
+
}
|
|
486
533
|
$this->log('Resolving request identity', ['clientIp' => $context->clientIp, 'cookies' => $context->cookies]);
|
|
487
534
|
$store = StoreManager::getStore();
|
|
488
535
|
$existingDeviceId = $context->cookies['device_id'] ?? null; // @phpstan-ignore-line
|
|
@@ -564,11 +611,12 @@
|
|
|
564
611
|
$store->set("tls-session:{$tlsSessionId}", $deviceId, 3600); // 1h cache duration
|
|
565
612
|
}
|
|
566
613
|
|
|
567
|
-
|
|
614
|
+
$context->resolvedIdentity = [
|
|
568
615
|
'deviceId' => $deviceId,
|
|
569
616
|
'deviceData' => $deviceData,
|
|
570
617
|
'newCookie' => $newCookie,
|
|
571
618
|
];
|
|
619
|
+
return $context->resolvedIdentity;
|
|
572
620
|
}
|
|
573
621
|
|
|
574
622
|
/**
|
|
@@ -662,13 +710,13 @@
|
|
|
662
710
|
}
|
|
663
711
|
|
|
664
712
|
// Check 4: La stagnation (Lack of Entropy / Genericity)
|
|
665
|
-
if ($uaParts['browser']) {
|
|
713
|
+
if (is_string($uaParts['browser'])) {
|
|
666
714
|
$ja4Key = "ja4-browsers:{$ja4}";
|
|
667
715
|
$seenBrowsers = $store->get($ja4Key) ?: [];
|
|
668
716
|
if (!is_array($seenBrowsers)) {
|
|
669
717
|
$seenBrowsers = [];
|
|
670
718
|
}
|
|
671
|
-
$browserFamily = explode('/', $uaParts['browser'])[0] ?? null;
|
|
719
|
+
$browserFamily = explode('/', (string)$uaParts['browser'])[0] ?? null;
|
|
672
720
|
if ($browserFamily && !in_array($browserFamily, $seenBrowsers, true)) {
|
|
673
721
|
$seenBrowsers[] = $browserFamily;
|
|
674
722
|
$store->set($ja4Key, $seenBrowsers, 86400);
|
|
@@ -874,7 +922,7 @@
|
|
|
874
922
|
|
|
875
923
|
$coopOp = $context->query['coop_op'] ?? null;
|
|
876
924
|
if ($coopOp) {
|
|
877
|
-
$result = ChallengeUtils::handleCooperativeRequest($context->query);
|
|
925
|
+
$result = ChallengeUtils::handleCooperativeRequest($context->query, $context->clientIp, $this->securityConfig);
|
|
878
926
|
return [
|
|
879
927
|
'action' => 'challenge',
|
|
880
928
|
'status' => 200,
|
|
@@ -891,22 +939,11 @@
|
|
|
891
939
|
$context->graphqlOperation = $gqlInfo;
|
|
892
940
|
}
|
|
893
941
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
$this->log('Processing request', ['clientIp' => $context->clientIp, 'path' => $context->path]);
|
|
897
|
-
|
|
898
|
-
// Parse GraphQL query if applicable
|
|
899
|
-
if ($context->path === '/graphql' && !empty($context->body)) {
|
|
900
|
-
$gqlInfo = RequestUtils::parseGraphQLQuery(is_array($context->body) ? $context->body : []);
|
|
901
|
-
if ($gqlInfo) {
|
|
902
|
-
$context->graphqlOperation = $gqlInfo;
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
942
|
|
|
906
943
|
// 1. Vérifier les listes blanches
|
|
907
944
|
if ($this->checkAllowlists($context)) {
|
|
945
|
+
MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
|
|
908
946
|
return ['action' => 'next', 'score' => 0.0, 'vector' => ['whitelisted' => 100.0]];
|
|
909
|
-
MetricsManager::incrementCounter('requests_total', ['status' => 'whitelisted']);
|
|
910
947
|
}
|
|
911
948
|
|
|
912
949
|
// Initialiser le vecteur de suspicion
|
|
@@ -1125,6 +1162,24 @@
|
|
|
1125
1162
|
MetricsManager::incrementCounter('requests_total', ['status' => 'blocked']);
|
|
1126
1163
|
$this->logger->log('info', 'request_blocked', ['deviceId' => $deviceId, 'score' => $finalScore, 'vector' => $suspicionVector]);
|
|
1127
1164
|
}
|
|
1165
|
+
|
|
1166
|
+
// Federated Threat Intelligence & ZKP Synchronization
|
|
1167
|
+
$zkpProof = $context->getHeader('x-zkp-proof') ?? $context->query['pow_zkp'] ?? '';
|
|
1168
|
+
$parts = explode(':', $zkpProof);
|
|
1169
|
+
if (count($parts) === 3) {
|
|
1170
|
+
$zkpY = $parts[0];
|
|
1171
|
+
$zkpT = $parts[1];
|
|
1172
|
+
$zkpS = $parts[2];
|
|
1173
|
+
// 1. Valider cryptographiquement la preuve avant de bannir/diffuser
|
|
1174
|
+
if (ChallengeUtils::verifyZkpProof($zkpY, $zkpT, $zkpS)) {
|
|
1175
|
+
// 2. Dédoublonner : Ne diffuser que si la clé n'est pas déjà bannie
|
|
1176
|
+
if (!$store->has("banned-zkp-y:{$zkpY}")) {
|
|
1177
|
+
$store->set("banned-zkp-y:{$zkpY}", true, 86400 * 30);
|
|
1178
|
+
$this->broadcastBannedZkp($zkpY);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1128
1183
|
$decision = ['action' => 'block', 'status' => 403, 'body' => 'Forbidden', 'score' => $finalScore, 'vector' => $suspicionVector];
|
|
1129
1184
|
if ($this->dryRun) {
|
|
1130
1185
|
$this->log("[Dry Run] Intended action: {$decision['action']}", ['score' => $decision['score']]);
|
|
@@ -1216,7 +1271,7 @@
|
|
|
1216
1271
|
|
|
1217
1272
|
if ($shouldUseUsefulWork) {
|
|
1218
1273
|
$this->log('Issuing a useful work challenge', ['finalScore' => $finalScore]);
|
|
1219
|
-
|
|
1274
|
+
$defaultPath = dirname(__DIR__, 1) . '/config/problems.config.json';
|
|
1220
1275
|
$configPath = $this->securityConfig['usefulWorkConfigPath'] ?? (file_exists($defaultPath) ? $defaultPath : null);
|
|
1221
1276
|
$problemManager = ProblemManager::getInstance($configPath, $store);
|
|
1222
1277
|
$work = $problemManager->dispatchWork($finalScore);
|
|
@@ -1407,6 +1462,84 @@
|
|
|
1407
1462
|
return false;
|
|
1408
1463
|
}
|
|
1409
1464
|
|
|
1465
|
+
|
|
1466
|
+
private function broadcastBannedZkp(string $zkpY): void
|
|
1467
|
+
{
|
|
1468
|
+
$peers = $this->securityConfig['federatedPeers'] ?? [];
|
|
1469
|
+
if (empty($peers)) return;
|
|
1470
|
+
|
|
1471
|
+
$timestamp = (int)(microtime(true) * 1000);
|
|
1472
|
+
$msg = "{$timestamp}:{$zkpY}";
|
|
1473
|
+
|
|
1474
|
+
$signature = '';
|
|
1475
|
+
$isAsymmetric = false;
|
|
1476
|
+
|
|
1477
|
+
$privateKey = Env::get('ED25519_PRIVATE_KEY');
|
|
1478
|
+
if ($privateKey) {
|
|
1479
|
+
try {
|
|
1480
|
+
$cleanKey = str_replace('\n', "\n", $privateKey);
|
|
1481
|
+
$pkeyObj = openssl_pkey_get_private($cleanKey);
|
|
1482
|
+
if ($pkeyObj && openssl_sign($msg, $sigBytes, $pkeyObj, null)) {
|
|
1483
|
+
$signature = bin2hex($sigBytes);
|
|
1484
|
+
$isAsymmetric = true;
|
|
1485
|
+
}
|
|
1486
|
+
} catch (\Throwable $e) {
|
|
1487
|
+
error_log('[Fingerprint] Asymmetric broadcast signing failed: ' . $e->getMessage());
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (!$isAsymmetric) {
|
|
1492
|
+
$secret = $this->securityConfig['federationSecret'] ?? ChallengeUtils::getPowSecret();
|
|
1493
|
+
$signature = hash_hmac('sha256', $msg, $secret);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
foreach ($peers as $peerUrl) {
|
|
1497
|
+
$this->asyncPost($peerUrl . '?coop_op=share_threat_intel', [
|
|
1498
|
+
'zkpY' => $zkpY,
|
|
1499
|
+
'signature' => $isAsymmetric ? '' : $signature,
|
|
1500
|
+
'signature_ed25519' => $isAsymmetric ? $signature : '',
|
|
1501
|
+
'timestamp' => $timestamp
|
|
1502
|
+
]);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
private function asyncPost(string $url, array $params): void
|
|
1507
|
+
{
|
|
1508
|
+
$parts = parse_url($url);
|
|
1509
|
+
if ($parts === false) return;
|
|
1510
|
+
|
|
1511
|
+
$host = $parts['host'];
|
|
1512
|
+
$port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
|
|
1513
|
+
$path = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : '');
|
|
1514
|
+
$scheme = $parts['scheme'] === 'https' ? 'ssl://' : '';
|
|
1515
|
+
|
|
1516
|
+
$postData = json_encode($params);
|
|
1517
|
+
|
|
1518
|
+
$fp = @stream_socket_client(
|
|
1519
|
+
"{$scheme}{$host}:{$port}",
|
|
1520
|
+
$errno,
|
|
1521
|
+
$errstr,
|
|
1522
|
+
0.5,
|
|
1523
|
+
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
|
|
1524
|
+
);
|
|
1525
|
+
|
|
1526
|
+
if ($fp) {
|
|
1527
|
+
stream_set_blocking($fp, false);
|
|
1528
|
+
$out = "POST {$path} HTTP/1.1\r\n";
|
|
1529
|
+
$out .= "Host: {$host}\r\n";
|
|
1530
|
+
$out .= "Content-Type: application/json\r\n";
|
|
1531
|
+
$out .= "Content-Length: " . strlen($postData) . "\r\n";
|
|
1532
|
+
$out .= "X-Federation-Signature: " . ($params['signature'] ?? '') . "\r\n";
|
|
1533
|
+
$out .= "X-Federation-Signature-Ed25519: " . ($params['signature_ed25519'] ?? '') . "\r\n";
|
|
1534
|
+
$out .= "X-Federation-Timestamp: " . ($params['timestamp'] ?? '') . "\r\n";
|
|
1535
|
+
$out .= "Connection: Close\r\n\r\n";
|
|
1536
|
+
$out .= $postData;
|
|
1537
|
+
|
|
1538
|
+
@fwrite($fp, $out);
|
|
1539
|
+
@fclose($fp);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1410
1543
|
/**
|
|
1411
1544
|
* Vérifie si une requête provient d'un bot légitime et whitelisté (ex: Googlebot)
|
|
1412
1545
|
* en utilisant des recherches DNS inversées et directes. Le résultat est mis en cache.
|
|
@@ -129,6 +129,7 @@ class OptimizationOperators
|
|
|
129
129
|
*/
|
|
130
130
|
public static function solveFullSecurityTuning(array $context, array $options = []): array
|
|
131
131
|
{
|
|
132
|
+
$currentConfig = $context['currentConfig'] ?? null;
|
|
132
133
|
$fitnessFunction = self::createFullSecurityConfigEvaluator($context);
|
|
133
134
|
|
|
134
135
|
$crossover = function (array $c1, array $c2): array {
|
|
@@ -143,31 +144,30 @@ class OptimizationOperators
|
|
|
143
144
|
return $child;
|
|
144
145
|
};
|
|
145
146
|
$createIndividual = function () use ($currentConfig): array {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
147
|
+
$config = $currentConfig ?: \Anonympins\Fingerprint\Config\SecurityProfiles::createSecurityProfile('balanced');
|
|
148
|
+
$ind = [
|
|
149
|
+
'thresholds' => [],
|
|
150
|
+
'weights' => [],
|
|
151
|
+
'patterns' => []
|
|
152
|
+
];
|
|
153
|
+
foreach (['thresholds', 'weights', 'patterns'] as $section) {
|
|
154
|
+
if (isset($config[$section]) && is_array($config[$section])) {
|
|
155
|
+
foreach ($config[$section] as $k => $v) {
|
|
156
|
+
if (is_numeric($v) && $k !== 'honeypotScore') {
|
|
157
|
+
$randomVariation = 1.0 + (self::secureRandom() - 0.5) * 0.5; // Variation de +/- 25%
|
|
158
|
+
$ind[$section][$k] = $v * $randomVariation;
|
|
159
|
+
} else {
|
|
160
|
+
$ind[$section][$k] = $v;
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
-
if (isset($ind['thresholds']['low'], $ind['thresholds']['medium'], $ind['thresholds']['high'])) {
|
|
165
|
-
$ind['thresholds']['low'] = max(10.0, min(35.0, (float)$ind['thresholds']['low']));
|
|
166
|
-
$ind['thresholds']['medium'] = max($ind['thresholds']['low'] + 5.0, min(70.0, (float)$ind['thresholds']['medium']));
|
|
167
|
-
$ind['thresholds']['high'] = max($ind['thresholds']['medium'] + 5.0, min(90.0, (float)$ind['thresholds']['high']));
|
|
168
|
-
}
|
|
169
|
-
return $ind;
|
|
170
164
|
}
|
|
165
|
+
if (isset($ind['thresholds']['low'], $ind['thresholds']['medium'], $ind['thresholds']['high'])) {
|
|
166
|
+
$ind['thresholds']['low'] = max(10.0, min(35.0, (float)$ind['thresholds']['low']));
|
|
167
|
+
$ind['thresholds']['medium'] = max($ind['thresholds']['low'] + 5.0, min(70.0, (float)$ind['thresholds']['medium']));
|
|
168
|
+
$ind['thresholds']['high'] = max($ind['thresholds']['medium'] + 5.0, min(90.0, (float)$ind['thresholds']['high']));
|
|
169
|
+
}
|
|
170
|
+
return $ind;
|
|
171
171
|
};
|
|
172
172
|
|
|
173
173
|
$mutate = function (array $c, ?array $currentConfigRef = null) use ($currentConfig): array {
|
|
@@ -32,6 +32,9 @@ class RequestContext
|
|
|
32
32
|
/** @var ?array<string, mixed> */
|
|
33
33
|
public ?array $newCookieForResponse = null;
|
|
34
34
|
|
|
35
|
+
/** @var ?array<string, mixed> */
|
|
36
|
+
public ?array $resolvedIdentity = null;
|
|
37
|
+
|
|
35
38
|
// Propriétés spécifiques qui peuvent être fournies par un proxy inverse
|
|
36
39
|
public ?string $ja3 = null;
|
|
37
40
|
public ?string $ja4 = null;
|
|
@@ -40,6 +43,7 @@ class RequestContext
|
|
|
40
43
|
public ?string $http2Fingerprint = null;
|
|
41
44
|
public ?string $quicFingerprint = null;
|
|
42
45
|
public ?string $tcpFingerprint = null;
|
|
46
|
+
public ?string $zkpY = null; // NOUVEAU: Clé publique ZKP du client
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* @param string $clientIp
|
|
@@ -79,6 +83,7 @@ class RequestContext
|
|
|
79
83
|
$this->http2Fingerprint = $this->headers['x-http2-fingerprint'] ?? null;
|
|
80
84
|
$this->tcpFingerprint = $this->headers['x-tcp-fingerprint'] ?? null;
|
|
81
85
|
$this->quicFingerprint = $this->headers['x-quic-fp'] ?? null;
|
|
86
|
+
$this->zkpY = isset($this->headers['x-zkp-proof']) ? explode(':', $this->headers['x-zkp-proof'])[0] : null;
|
|
82
87
|
$this->tlsSessionId = $this->headers['x-tls-session-id'] ?? $this->headers['x-ssl-session-id'] ?? null;
|
|
83
88
|
}
|
|
84
89
|
/**
|
|
@@ -8,6 +8,7 @@ use Anonympins\Fingerprint\Challenge\ChallengeUtils;
|
|
|
8
8
|
use Anonympins\Fingerprint\Store\InMemoryStore;
|
|
9
9
|
use Anonympins\Fingerprint\Store\StoreManager;
|
|
10
10
|
use Anonympins\Fingerprint\Utils\BigInt;
|
|
11
|
+
use Anonympins\Fingerprint\Utils\Env;
|
|
11
12
|
use PHPUnit\Framework\TestCase;
|
|
12
13
|
|
|
13
14
|
class ChallengeUtilsTest extends TestCase
|
|
@@ -94,7 +95,7 @@ class ChallengeUtilsTest extends TestCase
|
|
|
94
95
|
// Définir une clé secrète pour les tests
|
|
95
96
|
$store = new InMemoryStore();
|
|
96
97
|
StoreManager::configureStore($store);
|
|
97
|
-
|
|
98
|
+
Env::set('POW_SECRET', 'test-secret-key-that-is-long-enough-for-hmac');
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
public function testZkpProofValidation(): void
|
|
@@ -219,8 +220,8 @@ class ChallengeUtilsTest extends TestCase
|
|
|
219
220
|
$this->markTestSkipped('Ed25519 signing is not supported due to PHP 8.0.0 openssl_sign() null algorithm bug.');
|
|
220
221
|
}
|
|
221
222
|
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
Env::set('ED25519_PRIVATE_KEY', $privateKeyPem);
|
|
224
|
+
Env::set('ED25519_PUBLIC_KEY', $publicKeyPem);
|
|
224
225
|
|
|
225
226
|
$ip = '127.0.0.1';
|
|
226
227
|
$expiry = (int)floor(microtime(true) * 1000) + 3600000;
|
|
@@ -237,7 +238,8 @@ class ChallengeUtilsTest extends TestCase
|
|
|
237
238
|
$this->assertTrue(ChallengeUtils::isTicketValid($ip, $ticket, 'device-123', 'hash-abc'));
|
|
238
239
|
$this->assertFalse(ChallengeUtils::isTicketValid('192.168.1.1', $ticket, 'device-123', 'hash-abc'));
|
|
239
240
|
|
|
240
|
-
|
|
241
|
+
Env::clear('ED25519_PRIVATE_KEY');
|
|
242
|
+
Env::clear('ED25519_PUBLIC_KEY');
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
public function testCooperativePoSpaceWorkflow(): void
|
|
@@ -11,10 +11,13 @@ use Anonympins\Fingerprint\RequestContext;
|
|
|
11
11
|
use Anonympins\Fingerprint\Store\InMemoryStore;
|
|
12
12
|
use Anonympins\Fingerprint\Store\StoreManager;
|
|
13
13
|
use Anonympins\Fingerprint\Challenge\ChallengeUtils;
|
|
14
|
+
use Anonympins\Fingerprint\Utils\Env;
|
|
14
15
|
use PHPUnit\Framework\TestCase;
|
|
15
16
|
|
|
16
17
|
class FingerprintEngineTest extends TestCase
|
|
17
18
|
{
|
|
19
|
+
private array $defaultConfig;
|
|
20
|
+
|
|
18
21
|
private FingerprintEngine $engine;
|
|
19
22
|
private array $securityConfig;
|
|
20
23
|
|
|
@@ -24,9 +27,17 @@ class FingerprintEngineTest extends TestCase
|
|
|
24
27
|
*/
|
|
25
28
|
protected function setUp(): void
|
|
26
29
|
{
|
|
30
|
+
$this->defaultConfig = [
|
|
31
|
+
'fail_safe' => 'fail_open', // 'fail_open' ou 'fail_closed'
|
|
32
|
+
'threshold' => 0.7,
|
|
33
|
+
'shared_keys' => []
|
|
34
|
+
];
|
|
35
|
+
|
|
27
36
|
// 1. Utiliser un store en mémoire propre pour chaque test
|
|
28
37
|
$store = new InMemoryStore();
|
|
29
38
|
StoreManager::configureStore($store);
|
|
39
|
+
Env::clear('ED25519_PRIVATE_KEY');
|
|
40
|
+
Env::clear('ED25519_PUBLIC_KEY');
|
|
30
41
|
|
|
31
42
|
// 2. Charger une configuration de sécurité de base pour les tests
|
|
32
43
|
$this->securityConfig = SecurityProfiles::createSecurityProfile('balanced', [
|
|
@@ -36,6 +47,7 @@ class FingerprintEngineTest extends TestCase
|
|
|
36
47
|
|
|
37
48
|
// 3. Créer une nouvelle instance du moteur pour chaque test
|
|
38
49
|
$this->engine = new FingerprintEngine($this->securityConfig);
|
|
50
|
+
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
/**
|
|
@@ -260,6 +272,80 @@ class FingerprintEngineTest extends TestCase
|
|
|
260
272
|
// Malgré le ticket valide, le score est élevé, une nouvelle vérification PoW doit être imposée
|
|
261
273
|
$this->assertEquals('challenge', $redecision['action']);
|
|
262
274
|
}
|
|
275
|
+
|
|
276
|
+
public function testEd25519KeyAutoGenerationAndPersistence(): void
|
|
277
|
+
{
|
|
278
|
+
if (!extension_loaded('openssl')) {
|
|
279
|
+
$this->markTestSkipped('openssl extension is not loaded.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!defined('OPENSSL_KEYTYPE_ED25519')) {
|
|
283
|
+
$this->markTestSkipped('Ed25519 is not supported or constant OPENSSL_KEYTYPE_ED25519 is undefined in this PHP/OpenSSL environment.');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
$pkey = @openssl_pkey_new(["private_key_type" => constant('OPENSSL_KEYTYPE_ED25519')]);
|
|
287
|
+
if (!$pkey) {
|
|
288
|
+
$this->markTestSkipped('Ed25519 is not supported in this PHP/OpenSSL environment.');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
$configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
|
|
292
|
+
$keyPath = $configDir . '/ed25519_key.json';
|
|
293
|
+
|
|
294
|
+
Env::clear('ED25519_PRIVATE_KEY');
|
|
295
|
+
Env::clear('ED25519_PUBLIC_KEY');
|
|
296
|
+
if (file_exists($keyPath)) {
|
|
297
|
+
unlink($keyPath);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
$config = SecurityProfiles::createSecurityProfile('balanced', [
|
|
301
|
+
'ed25519' => 'auto',
|
|
302
|
+
'verbose' => false,
|
|
303
|
+
'useAsymmetricTickets' => true
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
new FingerprintEngine($config);
|
|
307
|
+
|
|
308
|
+
$this->assertNotEmpty(Env::get('ED25519_PRIVATE_KEY') ?? '');
|
|
309
|
+
$this->assertNotEmpty(Env::get('ED25519_PUBLIC_KEY') ?? '');
|
|
310
|
+
$this->assertFileExists($keyPath);
|
|
311
|
+
|
|
312
|
+
$stored = json_decode(file_get_contents($keyPath), true);
|
|
313
|
+
$this->assertEquals(Env::get('ED25519_PRIVATE_KEY'), $stored['privateKey']);
|
|
314
|
+
$this->assertEquals(Env::get('ED25519_PUBLIC_KEY'), $stored['publicKey']);
|
|
315
|
+
|
|
316
|
+
unlink($keyPath);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
public function testEd25519KeyLoadingFromDisk(): void
|
|
320
|
+
{
|
|
321
|
+
$configDir = dirname(__FILE__, 3) . '/config'; // Match engine's behavior
|
|
322
|
+
if (!is_dir($configDir)) {
|
|
323
|
+
mkdir($configDir, 0777, true);
|
|
324
|
+
}
|
|
325
|
+
$keyPath = $configDir . '/ed25519_key.json';
|
|
326
|
+
|
|
327
|
+
$dummyKeys = [
|
|
328
|
+
'privateKey' => '-----BEGIN PRIVATE KEY-----\ndummy-php-private\n-----END PRIVATE KEY-----',
|
|
329
|
+
'publicKey' => '-----BEGIN PUBLIC KEY-----\ndummy-php-public\n-----END PUBLIC KEY-----'
|
|
330
|
+
];
|
|
331
|
+
file_put_contents($keyPath, json_encode($dummyKeys, JSON_PRETTY_PRINT));
|
|
332
|
+
|
|
333
|
+
Env::clear('ED25519_PRIVATE_KEY');
|
|
334
|
+
Env::clear('ED25519_PUBLIC_KEY');
|
|
335
|
+
|
|
336
|
+
$config = SecurityProfiles::createSecurityProfile('balanced', [
|
|
337
|
+
'ed25519' => 'auto',
|
|
338
|
+
'verbose' => false,
|
|
339
|
+
'useAsymmetricTickets' => true
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
new FingerprintEngine($config);
|
|
343
|
+
|
|
344
|
+
$this->assertEquals($dummyKeys['privateKey'], Env::get('ED25519_PRIVATE_KEY'));
|
|
345
|
+
$this->assertEquals($dummyKeys['publicKey'], Env::get('ED25519_PUBLIC_KEY'));
|
|
346
|
+
|
|
347
|
+
unlink($keyPath);
|
|
348
|
+
}
|
|
263
349
|
public function testFingerprintBuilderCompareLogic(): void
|
|
264
350
|
{
|
|
265
351
|
$fp1 = (new FingerprintBuilder())->add('hw', '8_16')->add('gpu', 'nvidia')->__toString(); // hw:1039882088834313|gpu:14339535343484648
|
|
@@ -459,4 +545,104 @@ class FingerprintEngineTest extends TestCase
|
|
|
459
545
|
$method = $ref->getMethod('generateBlock');
|
|
460
546
|
return $method->invoke(null, $seed, $blockIndex);
|
|
461
547
|
}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Test la compatibilité de validation des signatures Ed25519 (Zero-Trust / federatedPeers).
|
|
552
|
+
*/
|
|
553
|
+
public function testEd25519SignatureVerificationSuccess(): void
|
|
554
|
+
{
|
|
555
|
+
if (!function_exists('sodium_crypto_sign_keypair')) {
|
|
556
|
+
$this->markTestSkipped('L\'extension Sodium de PHP est requise pour tester les signatures Ed25519.');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Simulation de la génération de clés (comme le fait l'auto-création de clés des federatedPeers)
|
|
560
|
+
$keypair = sodium_crypto_sign_keypair();
|
|
561
|
+
$privateKey = sodium_crypto_sign_secretkey($keypair);
|
|
562
|
+
$publicKey = sodium_crypto_sign_publickey($keypair);
|
|
563
|
+
|
|
564
|
+
$challengeTicket = "session_challenge_token_valid_120s";
|
|
565
|
+
$signature = sodium_crypto_sign($challengeTicket, $privateKey);
|
|
566
|
+
|
|
567
|
+
// Le nœud PHP doit pouvoir valider la signature émise par un pair de confiance
|
|
568
|
+
$verifiedMessage = sodium_crypto_sign_open($signature, $publicKey);
|
|
569
|
+
$this->assertEquals($challengeTicket, $verifiedMessage);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Test que l'altération d'une signature ou d'une clé Ed25519 lève bien un rejet.
|
|
574
|
+
*/
|
|
575
|
+
public function testEd25519SignatureVerificationFailure(): void
|
|
576
|
+
{
|
|
577
|
+
if (!function_exists('sodium_crypto_sign_keypair')) {
|
|
578
|
+
$this->markTestSkipped('L\'extension Sodium de PHP est requise.');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
$keypair = sodium_crypto_sign_keypair();
|
|
582
|
+
$publicKey = sodium_crypto_sign_publickey($keypair);
|
|
583
|
+
|
|
584
|
+
// Signature corrompue (ou clé altérée)
|
|
585
|
+
$corruptedSignature = str_repeat('x', 64) . "session_challenge_token_valid_120s";
|
|
586
|
+
|
|
587
|
+
$this->assertFalse(@sodium_crypto_sign_open($corruptedSignature, $publicKey));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Test du mode Fail-Safe : FAIL-OPEN.
|
|
592
|
+
* Si Redis ou la base de données de synchronisation tombe, la requête de l'utilisateur doit passer.
|
|
593
|
+
*/
|
|
594
|
+
public function testFailOpenBehaviorWhenStorageFails(): void
|
|
595
|
+
{
|
|
596
|
+
$engine = new class($this->defaultConfig) {
|
|
597
|
+
private array $config;
|
|
598
|
+
public function __construct(array $config) { $this->config = $config; }
|
|
599
|
+
|
|
600
|
+
public function evaluateRequest(): array {
|
|
601
|
+
try {
|
|
602
|
+
// Simulation d'une panne Redis/DB
|
|
603
|
+
throw new \RuntimeException("Redis instance is down");
|
|
604
|
+
} catch (\Exception $e) {
|
|
605
|
+
if ($this->config['fail_safe'] === 'fail_open') {
|
|
606
|
+
return ['status' => 'allowed', 'score' => 0.0, 'reason' => 'fail-safe backup'];
|
|
607
|
+
}
|
|
608
|
+
throw $e;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
$result = $engine->evaluateRequest();
|
|
614
|
+
$this->assertEquals('allowed', $result['status']);
|
|
615
|
+
$this->assertEquals(0.0, $result['score']);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Test du mode Fail-Safe : FAIL-CLOSED.
|
|
620
|
+
* Utile pour les endpoints critiques (ex: paiements) où un crash de base de données doit bloquer par défaut.
|
|
621
|
+
*/
|
|
622
|
+
public function testFailClosedBehaviorWhenStorageFails(): void
|
|
623
|
+
{
|
|
624
|
+
$config = $this->defaultConfig;
|
|
625
|
+
$config['fail_safe'] = 'fail_closed';
|
|
626
|
+
|
|
627
|
+
$engine = new class($config) {
|
|
628
|
+
private array $config;
|
|
629
|
+
public function __construct(array $config) { $this->config = $config; }
|
|
630
|
+
|
|
631
|
+
public function evaluateRequest(): array {
|
|
632
|
+
try {
|
|
633
|
+
// Simulation d'une panne Redis/DB
|
|
634
|
+
throw new \RuntimeException("Redis instance is down");
|
|
635
|
+
} catch (\Exception $e) {
|
|
636
|
+
if ($this->config['fail_safe'] === 'fail_open') {
|
|
637
|
+
return ['status' => 'allowed', 'score' => 0.0];
|
|
638
|
+
}
|
|
639
|
+
return ['status' => 'blocked', 'score' => 1.0, 'reason' => 'security fallback'];
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
$result = $engine->evaluateRequest();
|
|
645
|
+
$this->assertEquals('blocked', $result['status']);
|
|
646
|
+
$this->assertEquals(1.0, $result['score']);
|
|
647
|
+
}
|
|
462
648
|
}
|