format('Y-m-d H:i:s'); $stmt = $db->prepare( 'SELECT id, license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, settings_lock_password_hash, revoked_at FROM licenses WHERE license_key = ? LIMIT 1' ); $stmt->execute([$licenseKey]); $lic = $stmt->fetch(); if (!$lic) { self::audit($db, null, $ip, 'validate_invalid', ['key_prefix' => substr($licenseKey, 0, 6)]); Response::error('invalid', 'Clé de license invalide', 401); } if ($lic['revoked_at'] !== null) { self::audit($db, (int)$lic['id'], $ip, 'validate_revoked', []); Response::error('revoked', 'License révoquée', 403); } // Slot machine $slotStmt = $db->prepare( 'SELECT id FROM license_machines WHERE license_id = ? AND machine_id = ? LIMIT 1' ); $slotStmt->execute([$lic['id'], $machineId]); $existingSlot = $slotStmt->fetch(); if ($existingSlot) { $db->prepare('UPDATE license_machines SET last_seen = ? WHERE id = ?') ->execute([$now, $existingSlot['id']]); } else { $countStmt = $db->prepare('SELECT COUNT(*) AS c FROM license_machines WHERE license_id = ?'); $countStmt->execute([$lic['id']]); $count = (int)($countStmt->fetch()['c'] ?? 0); if ($count >= (int)$lic['max_machines']) { self::audit($db, (int)$lic['id'], $ip, 'machine_limit', ['count' => $count]); Response::error('machine_limit_exceeded', "Cette license autorise {$lic['max_machines']} machine(s) ; toutes les places sont prises.", 403, ['ownerName' => $lic['owner_name']]); } $db->prepare( 'INSERT INTO license_machines (license_id, machine_id, machine_label, first_seen, last_seen) VALUES (?, ?, ?, ?, ?)' )->execute([$lic['id'], $machineId, $machineLabel ?: null, $now, $now]); } // Construit la réponse, signe-la, renvoie. // Tous les timestamps sont émis en UTC + format figé "yyyy-MM-ddTHH:mm:ssZ" // pour que la canonicalisation côté client produise EXACTEMENT les mêmes bytes. $utc = new \DateTimeZone('UTC'); $fmt = 'Y-m-d\TH:i:s\Z'; $issuedAt = (new \DateTimeImmutable($lic['issued_at'], $utc))->format($fmt); $entUntil = (new \DateTimeImmutable($lic['download_entitlement_until'], $utc))->format($fmt); $serverTime = (new \DateTimeImmutable('now', $utc))->format($fmt); $expired = strtotime($lic['download_entitlement_until']) < time(); // ATTENTION : ordre des clés CRITIQUE pour la signature Ed25519. // Crypto::canonicalJson NE TRIE PAS — il prend l'ordre tel quel. Le client // C# (LicenseService.CanonicalBytesFor) reconstruit le même dictionnaire // dans le même ordre. Toute modif ici doit être miroir côté client. // channel : NULL en DB → null JSON (visible dans le canonical comme "channel":null). // can_see_betas : 0/1 en DB → bool JSON. $channel = isset($lic['channel']) && $lic['channel'] !== null && $lic['channel'] !== '' ? (string)$lic['channel'] : null; $canSeeBetas = (bool)($lic['can_see_betas'] ?? 0); // SettingsLockPasswordHash : présent depuis migration 003, NULL = pas de // verrouillage. On renvoie tel quel (null ou hex 64). Le launcher applique // côté ISettingsLockService.SetPasswordHash après validation signée. $settingsLockHash = isset($lic['settings_lock_password_hash']) && $lic['settings_lock_password_hash'] !== '' ? (string)$lic['settings_lock_password_hash'] : null; $payload = [ 'status' => $expired ? 'expired' : 'valid', 'licenseId' => 'lic_' . $lic['id'], 'ownerName' => $lic['owner_name'], 'issuedAt' => $issuedAt, 'downloadEntitlementUntil' => $entUntil, 'maxMachines' => (int)$lic['max_machines'], 'channel' => $channel, 'canSeeBetas' => $canSeeBetas, 'settingsLockPasswordHash' => $settingsLockHash, 'serverTime' => $serverTime, ]; // Signature Ed25519 du payload canonical $sk = $config['ed25519']['private_key_hex'] ?? ''; if ($sk !== '') { $payload['signature'] = Crypto::signEd25519(Crypto::canonicalJson($payload), $sk); } self::audit($db, (int)$lic['id'], $ip, $expired ? 'validate_expired' : 'validate_ok', [ 'launcher_version' => $launcherVer, ]); Response::json($payload, $expired ? 200 : 200); } private static function enforceRateLimit(array $config, string $ip): void { $max = (int)($config['validate_max_per_minute_per_ip'] ?? 10); if ($max <= 0) return; $db = Db::get($config); $now = new \DateTimeImmutable('now'); $window = $now->modify('-1 minute')->format('Y-m-d H:i:s'); $stmt = $db->prepare('SELECT counter, window_start FROM rate_limit WHERE ip = ?'); $stmt->execute([$ip]); $row = $stmt->fetch(); if ($row === false || $row['window_start'] < $window) { $db->prepare('REPLACE INTO rate_limit (ip, window_start, counter) VALUES (?, ?, 1)') ->execute([$ip, $now->format('Y-m-d H:i:s')]); return; } if ((int)$row['counter'] >= $max) { Response::error('rate_limited', 'Trop de tentatives. Réessaie dans 1 minute.', 429); } $db->prepare('UPDATE rate_limit SET counter = counter + 1 WHERE ip = ?')->execute([$ip]); } private static function audit(\PDO $db, ?int $licenseId, string $ip, string $event, array $detail): void { try { $db->prepare( 'INSERT INTO audit_log (ts, license_id, ip, event, detail) VALUES (NOW(), ?, ?, ?, ?)' )->execute([$licenseId, $ip, $event, json_encode($detail, JSON_UNESCAPED_UNICODE)]); } catch (\Throwable) { /* best-effort */ } } }