Compare commits

...
60 changed files with 5901 additions and 45 deletions

No files matched your search

+22
View File
@@ -56,6 +56,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\Admin
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Tests", "tests\GameLauncher.Tests\GameLauncher.Tests.csproj", "{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src\GameLauncher.Modules\GameLauncher.Modules.csproj", "{9EBFA383-7928-46F6-8958-F4B38287E380}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -136,6 +140,22 @@ Global
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.Build.0 = Release|Any CPU
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.ActiveCfg = Release|x64
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.Build.0 = Release|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.ActiveCfg = Debug|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.Build.0 = Debug|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.Build.0 = Release|Any CPU
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.ActiveCfg = Release|x64
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.Build.0 = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.ActiveCfg = Debug|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.Build.0 = Debug|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -151,6 +171,8 @@ Global
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F} = {266A4FF5-5752-46BA-803C-09E351720DB2}
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+33 -7
View File
@@ -37,6 +37,8 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés (voir §8) |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord (vues dans `src\GameLauncher\Views\Admin`) |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
@@ -47,14 +49,16 @@ src/GameLauncher/
├─ launcher.json Configuration éditeur : un jeu, ou plusieurs (voir launcher.multi-jeux.example.json)
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
│ Changelog.xaml (modèles des notes de version)
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
│ GameViewModel (machine à états d'un jeu),
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification)
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
```
## 3. Publier une version du jeu (ManifestBuilder)
@@ -371,7 +375,7 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
|---|---|
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, son `apiUrl` est ignorée (seule celle intégrée à l'exe compte) |
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
@@ -467,8 +471,30 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
## 8. Administration (Launcher API)
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils ;
le menu d'administration du launcher arrive en phase 2 (rien ne change pour les joueurs d'ici là).
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord) ;
les pages de gestion arrivent dans les phases suivantes. Rien ne change pour les joueurs.
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
vérifiée par le serveur, protège l'administration.
* **Adresse du serveur** : `"apiUrl"` dans `src\GameLauncher\launcher.json` (racine du fichier,
https). En Release, seule la valeur **intégrée à l'exe** est utilisée ; en Debug, celle du
`launcher.json` utilisé (ex. `--config` avec `http://127.0.0.1:5080/api/` pour un serveur local).
Sans `apiUrl`, le menu indique qu'il n'est relié à aucun serveur.
* **Se connecter** : choisir sa clé (dossier `%USERPROFILE%\.launcher-keys`, ou « Choisir un autre
fichier de clé… »), saisir sa phrase de passe. Verrouillage automatique après 10 min sans activité,
quand Windows est verrouillé, ou avec `Ctrl+L` ; fermer la fenêtre efface la clé de la mémoire.
* **« J'ai une invitation »** : crée la clé sur ce PC et l'enregistre avec le code reçu, puis
affiche l'**empreinte** à dicter à la personne qui approuve. Sans code : clé seule (premier
propriétaire, à déclarer sur le VPS). Deux rangements au choix :
* **fichier chiffré** (phrase de passe, 12 caractères min.) : copiable et sauvegardable ;
* **puce de sécurité (TPM 2.0) de ce PC** (code PIN, 6 caractères min.) : la clé est créée dans la
puce et ne peut pas être copiée. Proposé seulement après vérification réelle de la puce. Elle
disparaît avec la puce (carte mère, réinitialisation, Windows réinstallé) : gardez aussi une
clé fichier de secours. Sur le disque : `admin.tpmkey` (référence, sans secret) et `admin.pub`.
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code.
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
@@ -484,5 +510,5 @@ le menu d'administration du launcher arrive en phase 2 (rien ne change pour les
|---|---|
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) |
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) ; créer sa clé et se connecter : aussi depuis le launcher |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
+83 -28
View File
@@ -51,9 +51,9 @@ Conséquences :
┌──────────────────────────────┐ HTTPS ┌─────────────────────────────────────────────┐
│ GameLauncher.exe │──────────►│ nginx (TLS, limites, fichiers statiques) │
│ ├─ Module « Administration »│ │ ├─ /exovivalzombie/… manifest, zip, objects│
│ │ clé privée (Windows │ │ ├─ /launcher/… mise à jour launcher │
│ │ Hello/TPM ou fichier │ │ ├─ /catalog/… infos des jeux (ph. 3)│
│ │ chiffré) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
│ │ clé privée : fichier │ │ ├─ /launcher/… mise à jour launcher │
│ │ chiffré, ou puce TPM │ │ ├─ /catalog/… infos des jeux (ph. 3)│
│ │ du PC (non exportable) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
│ └─ GameLauncher.ApiClient │ │ ASP.NET Core, service systemd durci │
└──────────────────────────────┘ │ données : /var/lib/launcher-api (700) │
└─────────────────────────────────────────────┘
@@ -64,7 +64,9 @@ Conséquences :
| Projet | Cible | Rôle |
|---|---|---|
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; module Administration (phase 2) |
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu (inchangée) : manifest, téléchargement, installation… |
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques |
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
@@ -74,7 +76,8 @@ Conséquences :
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
(+ `Core` à partir de la phase 3) ; `GameLauncher` → `Core`, `Contracts`, `ApiClient`.
(+ `Core` à partir de la phase 3) ; `Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ;
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
## 5. Modules
@@ -96,35 +99,44 @@ Modules livrés en phase 1 : `System` (infos, santé), `Auth` (défi, connexion,
(`AdminPermissions`), protège ses routes avec `.RequirePermission(...)` et ses modifications avec
`.RequireSignedRequest()`.
### 5.2 Côté launcher (phase 2)
### 5.2 Côté launcher (phase 2 ✅)
La logique d'un module (services, ViewModels) est une bibliothèque **net8.0 sans WPF**, testée sans
interface ; le launcher WPF n'en fournit que l'**affichage** (un `DataTemplate` par type de page).
```csharp
public interface ILauncherModule
{
string Id { get; } // "admin", "news", "friends"…
string Id { get; } // "admin", puis "news", "friends"…
string DisplayName { get; }
void Register(IModuleRegistry registry);
}
public interface IModuleRegistry
{
void AddResources(Uri resourceDictionary); // modèles WPF du module (DataTemplate)
void AddAdminPanel(AdminPanelDescriptor panel); // page du menu d'administration
void AddPage(PageDescriptor page); // section de la fenêtre principale (futur)
void AddService<T>(T service) where T : class; // services partagés
// Plus tard : AddPage (section de la fenêtre principale), services partagés…
}
public sealed record AdminPanelDescriptor(
string Id, string Title, string IconKey, string RequiredPermission, int Order,
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
string Id, string Title, string Description, string IconKey,
string? RequiredPermission, // null : toute personne connectée
int Order, Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
```
* Le **menu d'administration** est une fenêtre « coquille » : navigation à gauche (pages fournies
par les modules, **filtrées selon les permissions** renvoyées par le serveur), en-tête avec
l'administrateur connecté, durée de session restante et bouton « Verrouiller ».
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), les jeux
déclarés dans `launcher.json`, les boîtes de dialogue, le journal.
* Les modules futurs ajoutent leurs pages joueurs (`AddPage`) **et** leurs pages d'administration
(`AddAdminPanel`) : ils s'intègrent au menu admin sans le modifier.
* `ModuleCatalog` rassemble les modules (déclarés dans `App.CreateModules()`), vérifie l'unicité des
identifiants et le format des permissions, et donne les pages **autorisées par les permissions**
renvoyées par le serveur (le serveur reste seul juge : masquer une page n'est qu'un confort).
* Le **menu d'administration** est une fenêtre « coquille » : à gauche l'administrateur connecté, les
pages des modules, le temps restant avant verrouillage et le bouton « Verrouiller » ; à droite la
page choisie.
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), le client de
l'API, les jeux déclarés dans `launcher.json`, les boîtes de dialogue (`IModuleDialogs`), la
navigation entre pages et le signalement d'une session perdue (retour à la connexion).
`AdminPanelViewModel.RunAsync` gère « occupé », les messages d'erreur et la session expirée.
* Ajouter une page à un module futur : une classe `AdminPanelViewModel` + son descripteur dans le
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
## 6. Authentification par clé publique/privée
@@ -135,11 +147,39 @@ public sealed record AdminPanelDescriptor(
* `keyId` = 16 premiers caractères hexadécimaux du SHA-256 de la clé publique (SPKI).
* **Empreinte** affichée aux humains : 5 groupes de 4 caractères (`AB12 CD34 EF56 7890 1A2B`, 80 bits),
calculée avec une séparation de domaine : elle ne se déduit pas du `keyId`, visible dans les listes.
* Stockage de la clé privée sur le PC de l'administrateur (phase 2) :
* **Windows Hello / TPM** si disponible : clé non exportable, liée au PC, déverrouillée par le
code PIN Windows (un PC = une clé ; un administrateur peut enregistrer plusieurs PC) ;
* sinon **fichier chiffré** (PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la clé de
signature du launcher) déverrouillé par une phrase de passe de 12 caractères minimum.
* Stockage de la clé privée sur le PC de l'administrateur :
* **fichier chiffré** (phase 2 ✅) : PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la
clé de signature du launcher — déverrouillé par une phrase de passe de 12 caractères minimum.
Dossier proposé : `%USERPROFILE%\.launcher-keys` (celui de `tools\admin-key.bat`). La clé de
signature du launcher (`launcher-signing*.key`), rangée dans le même dossier, n'est jamais
proposée ni acceptée. Une clé n'écrase jamais un fichier existant ;
* **puce de sécurité TPM 2.0** (phase 2b ✅, au choix lors de « J'ai une invitation ») : la clé
ECDSA P-256 est **générée dans la puce** par le fournisseur Windows « Microsoft Platform Crypto
Provider » et n'en sort jamais (non exportable) ; la puce signe elle-même. Clé de l'utilisateur
Windows courant, protégée par un **code PIN** (6 caractères minimum, saisi dans le launcher,
vérifié par la puce, qui se bloque temporairement après plusieurs codes faux). Sur le disque,
seul un fichier de référence `*.tpmkey` (nom de la clé dans la puce + clé publique, aucun
secret) et le `.pub` habituel. À la connexion, la clé publique de la puce est comparée à celle
de la référence. Le choix n'est **dégrisé qu'après une vérification réelle de la puce**
(création, signature et suppression d'une clé de test). Rien ne change côté serveur : même
format de clé publique P-256, mêmes signatures. Limites : la clé ne peut pas être sauvegardée
et disparaît avec la puce (carte mère, réinitialisation du TPM, Windows réinstallé) — garder
une clé « fichier » de secours ; le serveur ne peut pas prouver qu'une clé est dans une puce
(pas d'attestation) : c'est une protection pour l'administrateur, pas une règle du serveur ;
le blocage anti-essais est commun à toute la puce (les codes faux comptent aussi pour le code
PIN Windows Hello) ; avec un profil Windows itinérant, la clé n'est utilisable que sur le PC
qui l'a créée. Le fichier `.tpmkey` ne désigne que des clés nommées `GameLauncher.Admin.*`
du fournisseur de la puce, et sa clé publique est comparée à celle de la puce avant tout
essai du code PIN.
Écran de connexion : **« Supprimer cette clé de la puce »** (clés TPM uniquement, après
confirmation ; efface la clé de la puce, le `.tpmkey` et le `.pub` — à révoquer aussi sur le
serveur ; les clés fichier se suppriment à la main). Après un code faux, le nombre d'essais
restants est affiché ; si la puce est bloquée, un **décompte** indique quand elle acceptera de
nouveau un code (état lu dans la puce par TPM2_GetCapability via le service TBS : compteur,
maximum, intervalle de « guérison » ; estimation, PC allumé). Si cet état n'est pas lisible sur
le PC, une explication générale est affichée et un nouvel essai reste possible.
* Windows Hello (visage, empreinte) : écarté pour l'instant, son API ne crée que des clés RSA,
ce qui demanderait de faire évoluer le serveur.
### 6.2 Connexion (défi-réponse)
@@ -174,6 +214,17 @@ empêchent de réutiliser une signature auprès d'un autre serveur.
d'inactivité, 8 h au maximum**, 5 sessions par administrateur.
* À chaque requête : session valide **et** administrateur actif **et** clé active (une révocation
prend effet immédiatement, y compris depuis l'outil en ligne de commande).
* **Côté launcher** (phase 2) : la clé déchiffrée n'existe en mémoire que pendant la session. Le menu
se **verrouille** (session fermée, clé effacée, retour à la connexion) après **10 min sans activité**
dans la fenêtre, à la fin des 8 h, quand la **session Windows est verrouillée** (Win+L, veille),
sur `Ctrl+L` ou le bouton « Verrouiller », et si le serveur refuse la session (révocation). Tant
que l'administrateur est actif, la session est prolongée sur le serveur toutes les 4 min. Fermer
la fenêtre ou le launcher efface la clé, puis déconnecte la session. La phrase de passe n'est
jamais conservée (champ effacé dès la tentative de connexion).
* L'adresse de l'API (`apiUrl`) n'est lue, dans la version Release, que dans le `launcher.json`
**intégré à l'exe** : un fichier posé à côté (ou `--config`) ne peut pas envoyer les
administrateurs vers un autre serveur. Une valeur invalide est ignorée (le launcher des joueurs
démarre toujours) et expliquée à l'ouverture du menu.
### 6.4 Requêtes signées
@@ -236,6 +287,8 @@ Les permissions sont des chaînes « module.action » : un module futur déclare
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
(Phase 2 : étapes 3 et 4 dans le launcher ; création de l'invitation et approbation avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » de la phase 6.)
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
@@ -252,7 +305,8 @@ sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas"
## 9. Informations fixes et informations modifiables des jeux (phase 3)
Les jeux sont **toujours déclarés dans `launcher.json`** : le serveur ne peut ni ajouter ni retirer
un jeu, seulement modifier l'affichage des jeux déclarés. Les champs actuels de `launcher.json` se
un jeu, seulement modifier l'affichage des jeux déclarés. Comme `apiUrl`, la liste des jeux que le
menu d'administration peut modifier sera lue, en Release, dans le `launcher.json` intégré à l'exe. Les champs actuels de `launcher.json` se
répartissent ainsi :
| Champ | Reste dans l'exe | Modifiable (serveur) | Raison |
@@ -324,15 +378,16 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|---|---|---|
| **0** | Architecture (ce document) | ✅ |
| **1** | Contrats partagés ; Launcher API (modules, authentification par clé, sessions, requêtes signées, invitations/enrôlement/approbation/révocation, audit, limites, CLI) ; client API ; outil `AdminKeyTool` ; tests ; fichiers de déploiement (systemd, nginx) | ✅ |
| **2** | Launcher : hôte de modules, entrée cachée (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), stockage de clé (Windows Hello/TPM, fichier chiffré), connexion, « J'ai une invitation », tableau de bord | à faire |
| **2** | Launcher : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | à faire |
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
Chaque phase compile, est testée et laisse le launcher utilisable : tant que la phase 2 n'est pas
livrée, rien ne change pour les joueurs.
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
## 14. Choix écartés
+14 -4
View File
@@ -76,7 +76,13 @@ Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères
Note l'**empreinte** affichée (`AB12 CD34 EF56 7890 1A2B`).
> Phase 2 : le launcher saura créer la clé lui-même (Windows Hello / TPM) et importer ce fichier.
> Depuis la phase 2, le launcher sait aussi créer la clé : menu d'administration (Ctrl+Maj+Alt+A,
> ou 5 clics rapides sur le logo) › « J'ai une invitation », **sans code** → clé seule, même dossier,
> même format. Une clé créée par `admin-key.bat` est reconnue par le launcher, et inversement.
> Le launcher peut aussi créer la clé **dans la puce de sécurité (TPM) du PC** (non copiable,
> code PIN) : il écrit alors `admin.tpmkey` (référence, sans secret) et `admin.pub` ; c'est ce
> `.pub` qu'on déclare ci-dessous. Une clé TPM ne fonctionne que dans le launcher, sur ce PC
> (`admin-key.bat` n'utilise que des clés fichier) : garde une clé fichier de secours (§ 9, `add-key`).
## 5. Te déclarer propriétaire (PC puis VPS, une seule fois)
@@ -102,12 +108,15 @@ Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste d
## 7. Ajouter un administrateur
En attendant le menu d'administration du launcher (phases 2 et 6), avec `tools\admin-key.bat` :
Depuis la phase 2, la personne invitée peut créer et enregistrer sa clé **dans le launcher** (menu
d'administration › « J'ai une invitation », code collé) : l'étape 2 ci-dessous se fait alors sans
ligne de commande, et le launcher affiche l'empreinte à te dicter. Inviter et approuver se font avec
`tools\admin-key.bat` jusqu'à la page « Administrateurs » du launcher (phase 6) :
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
2. **Paul** : `admin-key.bat` (création de sa clé), puis
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>` ; il te communique
2. **Paul** : launcher › menu d'administration › « J'ai une invitation » (ou `admin-key.bat`, puis
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>`) ; il te communique
**par téléphone ou message** l'empreinte affichée.
3. **Toi** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
@@ -132,6 +141,7 @@ pas, le script remet la précédente en place.
|---|---|
| Voir les administrateurs, clés et empreintes | `sudo -u deploy /opt/launcher-api/GameLauncher.Server list` |
| PC perdu ou volé | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-key --key <identifiant>` |
| Clé TPM supprimée dans le launcher (« Supprimer cette clé de la puce ») | Même commande `revoke-key`, avec l'identifiant affiché par le launcher |
| Retirer un administrateur | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-admin --id <identifiant>` |
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
+19 -1
View File
@@ -36,8 +36,26 @@ public static class AdminKeyFile
using (var writer = new StreamWriter(stream))
writer.Write(pem);
// Clé publique : jamais écrasée non plus ; en cas d'échec, la clé privée qui vient d'être créée est retirée.
var publicPath = Path.ChangeExtension(path, PublicKeyExtension);
File.WriteAllText(publicPath, AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
try
{
using var stream = new FileStream(publicPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
using var writer = new StreamWriter(stream);
writer.Write(AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
}
catch
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// clé chiffrée laissée en place : sans danger
}
throw;
}
return publicPath;
}
@@ -32,6 +32,9 @@ public sealed class AdminSession : IAsyncDisposable
}
public string ServerId { get; }
/// <summary>Écart entre l'horloge du serveur et celle du PC (heure serveur = heure du PC + écart), mesuré à la connexion.</summary>
public TimeSpan ClockOffset => _clockOffset;
public string SessionId { get; }
public AdminProfile Profile { get; private set; }
public DateTimeOffset ExpiresAt { get; }
+1 -1
View File
@@ -6,7 +6,7 @@ namespace GameLauncher.ApiClient;
/// <summary>
/// Clé privée d'un administrateur, vue par le client : elle signe, elle ne sort jamais.
/// Implémentations : <see cref="EcdsaAdminSigner"/> (clé en mémoire, déverrouillée depuis un fichier
/// chiffré) ; Windows Hello / TPM dans le launcher (phase 2).
/// chiffré) ; clé de la puce TPM dans le launcher (Platform\TpmKeyStore, phase 2b).
/// </summary>
public interface IAdminSigner
{
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
/// </summary>
public sealed class LauncherCatalog
{
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder" };
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl" };
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\&lt;Publisher&gt;\GameLauncher).</summary>
public const string DefaultDataFolderName = "GameLauncher";
@@ -62,6 +62,19 @@ public sealed class LauncherCatalog
/// </summary>
public string? LauncherUpdateUrl { get; private set; }
/// <summary>
/// Adresse de la Launcher API (menu d'administration), ex. « https://launcher.lanisys.fr/api/ ».
/// Facultatif : sans cette valeur, le menu d'administration est désactivé. Le launcher publié ne
/// lit cette valeur que dans le launcher.json INTÉGRÉ à l'exe (voir App) : un fichier posé à côté
/// ne peut pas rediriger les administrateurs vers un autre serveur.
/// Une valeur invalide ne bloque jamais le launcher des joueurs : elle est ignorée
/// (<see cref="ApiUrl"/> null) et expliquée dans <see cref="ApiUrlError"/>.
/// </summary>
public string? ApiUrl { get; private set; }
/// <summary>Raison pour laquelle « apiUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? ApiUrlError { get; private set; }
/// <summary>
/// Nom du dossier des données du launcher et de sa clé de registre (« dataFolder » dans
/// launcher.json, « GameLauncher » par défaut). Indépendant de <see cref="LauncherId"/>, qui
@@ -240,6 +253,8 @@ public sealed class LauncherCatalog
new[] { single }, isMultiGameFormat: false)
{
LauncherUpdateUrl = ReadUpdateUrl(root),
ApiUrl = ReadApiUrl(root, out var singleApiUrlError),
ApiUrlError = singleApiUrlError,
DataFolderName = ReadDataFolder(root)
};
}
@@ -291,6 +306,8 @@ public sealed class LauncherCatalog
return new LauncherCatalog(launcherId, launcherName, publisher, defaultGame, games, isMultiGameFormat: true)
{
LauncherUpdateUrl = ReadUpdateUrl(root),
ApiUrl = ReadApiUrl(root, out var apiUrlError),
ApiUrlError = apiUrlError,
DataFolderName = ReadDataFolder(root)
};
}
@@ -315,6 +332,24 @@ public sealed class LauncherCatalog
return uri.ToString();
}
/// <summary>« apiUrl » : adresse https absolue, sans paramètres (http accepté seulement en local, pour les tests).</summary>
private static string? ReadApiUrl(JsonObject root, out string? error)
{
error = null;
var node = Get(root, "apiUrl");
if (node is null) return null;
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
{
error = "launcher.json : apiUrl ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"https://launcher.exemple.fr/api/\".";
return null;
}
return uri.AbsoluteUri.EndsWith('/') ? uri.AbsoluteUri : uri.AbsoluteUri + "/";
}
/// <summary>Lecture d'une propriété sans tenir compte de la casse (gameId / GameId…).</summary>
private static JsonNode? Get(JsonObject obj, string name)
{
@@ -0,0 +1,25 @@
using GameLauncher.Modules.Admin.ViewModels;
namespace GameLauncher.Modules.Admin;
/// <summary>
/// Module « Administration » : la fenêtre d'administration elle-même (connexion, invitation,
/// verrouillage) et sa page d'accueil. Les autres pages (Jeux, Notes de version, Publication,
/// Administrateurs, Audit) seront déclarées par leurs propres modules.
/// </summary>
public sealed class AdminModule : ILauncherModule
{
public string Id => "admin";
public string DisplayName => "Administration";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
DashboardPanelViewModel.PanelId,
"Tableau de bord",
"Votre compte, la session en cours et l'état du serveur.",
"ViewDashboard",
RequiredPermission: null,
Order: 0,
context => new DashboardPanelViewModel(context)));
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Module « Administration » du launcher (sans WPF) : clés de l'administrateur, connexion,
invitation, verrouillage de session, tableau de bord. Les écrans correspondants sont dans le
launcher (Views\Admin, Themes\Admin.xaml). Voir docs\ADMIN-ARCHITECTURE.md.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Modules.Admin</RootNamespace>
<AssemblyName>GameLauncher.Modules.Admin</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Connexion ouverte : session + clé déverrouillée (en mémoire, ou ouverte dans la puce TPM). La fermer
/// déconnecte la session sur le serveur et libère la clé : il faudra de nouveau la phrase de passe (ou le PIN).
/// </summary>
public sealed class AdminConnection : IAsyncDisposable
{
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(5);
private readonly IAdminSigner _signer;
private readonly IDisposable _key;
private int _disposed;
/// <param name="key">Ce qui retient la clé (clé en mémoire, poignée de la clé TPM) : libéré à la fermeture.</param>
internal AdminConnection(LauncherApiClient client, AdminSession session, IAdminSigner signer, IDisposable key, string keyPath,
bool isTpm = false)
{
Client = client;
Session = session;
_signer = signer;
_key = key;
KeyPath = keyPath;
IsTpm = isTpm;
}
public LauncherApiClient Client { get; }
public AdminSession Session { get; }
public string KeyPath { get; }
public string Fingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
/// <summary>Clé conservée dans la puce de sécurité (TPM) de ce PC.</summary>
public bool IsTpm { get; }
/// <summary>Fin de session (durée maximale), à l'heure du PC.</summary>
public DateTimeOffset LocalExpiresAt => Session.ExpiresAt - Session.ClockOffset;
/// <summary>
/// Libère d'abord la clé (la déconnexion n'est pas signée : elle n'en a pas besoin), puis ferme la
/// session sur le serveur — au mieux, sans bloquer : injoignable, elle expirera d'elle-même.
/// </summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_key.Dispose();
using var timeout = new CancellationTokenSource(LogoutTimeout);
try
{
await Session.LogoutAsync(timeout.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Warn($"Administration : déconnexion du serveur impossible ({ex.GetType().Name}) ; la session expirera d'elle-même.");
}
}
}
@@ -0,0 +1,193 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Résultat de la création d'une clé (et de son enregistrement auprès du serveur avec une invitation).</summary>
/// <param name="Enrollment">Réponse du serveur ; null si aucune invitation n'a été utilisée (clé seulement) ou sans réponse.</param>
/// <param name="EnrollmentUnconfirmed">
/// Invitation envoyée mais réponse du serveur perdue (délai dépassé, coupure, erreur 5xx) : le serveur a
/// peut-être enregistré la clé et consommé l'invitation. La clé est donc CONSERVÉE (message expliquant quoi faire).
/// </param>
/// <param name="IsTpm">Clé créée dans la puce de sécurité (TPM) de ce PC ; KeyPath est alors son fichier de référence.</param>
public sealed record AdminKeyCreation(string KeyPath, string PublicKeyPath, string KeyId, string Fingerprint,
EnrollmentResponse? Enrollment, string? EnrollmentUnconfirmed = null, bool IsTpm = false);
/// <summary>Clé fichier en mémoire, vue comme une clé « libérable » (même contrat que la clé TPM).</summary>
internal sealed class FileKeySigner : IDisposableAdminSigner
{
private readonly EcdsaAdminSigner _inner;
public FileKeySigner(EcdsaAdminSigner inner) => _inner = inner;
public byte[] PublicKey => _inner.PublicKey;
public string KeyId => _inner.KeyId;
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => _inner.SignAsync(message, ct);
public void Dispose() => _inner.Dispose();
}
/// <summary>
/// Création d'une clé d'administrateur sur ce PC — fichier chiffré par une phrase de passe, ou clé
/// non exportable dans la puce TPM protégée par un code PIN — et, avec un code d'invitation,
/// enregistrement auprès du serveur (preuve de possession). Si le serveur REFUSE (erreur 4xx), la clé
/// est supprimée (fichiers, et clé de la puce) ; sans réponse sûre, elle est conservée.
/// </summary>
public sealed class AdminEnrollmentService
{
private readonly LauncherApiClient _client;
private readonly IAdminTpm? _tpm;
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
public AdminEnrollmentService(LauncherApiClient client, IAdminTpm? tpm = null)
{
_client = client;
_tpm = tpm;
}
/// <param name="keyPath">Fichier .key (clé chiffrée) ou, avec <paramref name="useTpm"/>, fichier de référence .tpmkey.</param>
/// <param name="secret">Phrase de passe du fichier (12 caractères au moins) ou code PIN de la clé TPM (6 au moins).</param>
/// <param name="useTpm">Vrai : clé créée dans la puce de sécurité (TPM) de ce PC.</param>
public async Task<AdminKeyCreation> CreateKeyAsync(string keyPath, string secret, string? invitationCode, string? keyLabel,
CancellationToken ct = default, bool useTpm = false)
{
var minLength = useTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
if (secret.Length < minLength)
throw new ArgumentException(useTpm
? $"Le code PIN doit contenir au moins {minLength} caractères."
: $"La phrase de passe doit contenir au moins {minLength} caractères.");
string? code = null;
if (!string.IsNullOrWhiteSpace(invitationCode))
code = InvitationCodes.Normalize(invitationCode)
?? throw new ArgumentException("Code d'invitation mal saisi : 32 caractères (lettres A à Z, chiffres 2 à 7), tirets facultatifs.");
var label = string.IsNullOrWhiteSpace(keyLabel) ? null : ApiText.CleanLine(keyLabel, ApiText.MaxKeyLabelLength)
?? throw new ArgumentException($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus, sans caractère spécial invisible).");
var fullPath = Path.GetFullPath(keyPath);
if (AdminKeyLocator.IsReserved(fullPath))
throw new InvalidOperationException($"« {AdminKeyLocator.ReservedFileName} » est réservé à la clé de signature du launcher.");
var extension = useTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
if (!fullPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Le fichier de clé doit avoir l'extension {extension}.");
var publicPath = Path.ChangeExtension(fullPath, AdminKeyFile.PublicKeyExtension);
// Les deux types de clés partagent le même .pub : aucun des trois fichiers ne doit exister.
var otherPath = Path.ChangeExtension(fullPath, useTpm ? AdminKeyFile.PrivateKeyExtension : AdminTpmKeyFile.Extension);
if (File.Exists(fullPath) || File.Exists(publicPath) || File.Exists(otherPath))
throw new IOException($"Une clé existe déjà à cet emplacement : {Path.ChangeExtension(fullPath, null)}. Choisissez un autre nom.");
var tpm = useTpm ? _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.") : null;
// Génération (et chiffrement, ou accès à la puce : lents) hors du fil de l'interface.
string? tpmKeyName = null;
IDisposableAdminSigner signer;
if (tpm is not null)
{
var keyName = AdminTpmKeyFile.NewKeyName();
signer = await BackgroundWork.RunAsync(() =>
{
var created = tpm.CreateKey(keyName, secret);
try
{
AdminTpmKeyFile.Save(fullPath, keyName, created.PublicKey);
return created;
}
catch
{
created.Dispose();
DeleteTpmKey(tpm, keyName);
throw;
}
}, ct).ConfigureAwait(false);
tpmKeyName = keyName;
}
else
{
signer = await BackgroundWork.RunAsync<IDisposableAdminSigner>(() =>
{
var key = AdminKeyFile.Generate();
try
{
AdminKeyFile.Save(key, fullPath, secret);
return new FileKeySigner(new EcdsaAdminSigner(key));
}
catch
{
key.Dispose();
throw;
}
}, ct).ConfigureAwait(false);
}
try
{
EnrollmentResponse? enrollment = null;
string? unconfirmed = null;
if (code is not null)
{
try
{
enrollment = await _client.EnrollAsync(code, signer, label, ct).ConfigureAwait(false);
}
catch (Exception ex) when (IsDefinitiveRefusal(ex) || ex is CryptographicException or ObjectDisposedException)
{
// Refus certain (invitation invalide, expirée, déjà utilisée…), ou signature de la preuve
// impossible (puce bloquée…) avant tout envoi : rien n'a été enregistré.
signer.Dispose();
if (tpmKeyName is not null)
{
AdminTpmKeyFile.DeleteFiles(fullPath);
DeleteTpmKey(tpm!, tpmKeyName);
}
else
{
TryDelete(fullPath);
TryDelete(publicPath);
}
throw;
}
catch (Exception ex) when (ex is ApiException or OperationCanceledException or HttpRequestException or IOException)
{
unconfirmed = $"Le serveur n'a pas confirmé l'enregistrement ({ApiErrorText.Describe(ex)}). " +
"Votre clé est conservée : communiquez son empreinte à la personne qui vous a invité. " +
"Si elle ne voit aucune clé en attente à votre nom, demandez-lui une nouvelle invitation.";
}
}
var publicKey = signer.PublicKey;
return new AdminKeyCreation(fullPath, publicPath, AdminKeys.ComputeKeyId(publicKey), AdminKeys.ComputeFingerprint(publicKey),
enrollment, unconfirmed, IsTpm: tpmKeyName is not null);
}
finally
{
signer.Dispose();
}
}
private static void DeleteTpmKey(IAdminTpm tpm, string keyName)
{
try
{
tpm.DeleteKey(keyName);
}
catch (Exception ex)
{
Core.Utils.Log.Warn($"Administration : clé {keyName} non supprimée de la puce ({ex.Message}).");
}
}
/// <summary>Le serveur a répondu par un refus (4xx), ou la requête n'a pas pu partir (horloge) : rien d'enregistré.</summary>
private static bool IsDefinitiveRefusal(Exception ex) => ex is ApiException api &&
((api.Status is >= 400 and < 500 && api.Code != "invalid_response") || api.Code == "clock_skew");
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé en place : sans danger (clé chiffrée, jamais enregistrée sur le serveur)
}
}
}
@@ -0,0 +1,149 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Clé d'administrateur trouvée sur ce PC.</summary>
/// <param name="Path">Chemin complet du fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
/// <param name="Fingerprint">Empreinte (null si inconnue : fichier .pub absent).</param>
/// <param name="IsTpm">Clé conservée dans la puce de sécurité (TPM) de ce PC : déverrouillée par un code PIN.</param>
public sealed record AdminKeyEntry(string Path, string? KeyId, string? Fingerprint, bool IsTpm = false)
{
public string Name => System.IO.Path.GetFileNameWithoutExtension(Path);
public string Detail => Fingerprint is null ? "Empreinte inconnue (fichier .pub absent)" : $"Empreinte {Fingerprint}";
/// <summary>Libellé du secret demandé à la connexion.</summary>
public string SecretLabel => IsTpm ? "Code PIN de la clé (puce TPM)" : "Phrase de passe de la clé";
}
/// <summary>
/// Recherche des clés d'administrateur : dossier %USERPROFILE%\.launcher-keys (celui de
/// tools\admin-key.bat) et fichiers déjà utilisés. La clé de SIGNATURE DU LAUNCHER, rangée dans le
/// même dossier, n'est jamais proposée : elle sert à autre chose et ne doit pas circuler.
/// </summary>
public static class AdminKeyLocator
{
/// <summary>Clé de signature des mises à jour du launcher (tools\keygen-launcher.bat).</summary>
public const string ReservedFileName = "launcher-signing.key";
private const long MaxKeyFileBytes = 16 * 1024;
public static string DefaultDirectory =>
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".launcher-keys");
/// <summary>
/// Clé de signature du launcher, copies comprises (« launcher-signing - Copie.key »…) : ne doit jamais
/// être déverrouillée par le menu d'administration. Chemin normalisé (« launcher-signing.key. »…).
/// </summary>
public static bool IsReserved(string path)
{
string name;
try
{
name = System.IO.Path.GetFileName(System.IO.Path.GetFullPath(path)).TrimEnd('.', ' ');
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
return true; // chemin invalide : refusé
}
return name.StartsWith(System.IO.Path.GetFileNameWithoutExtension(ReservedFileName), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Clés du dossier <paramref name="directory"/> puis des chemins mémorisés (sans doublon).</summary>
public static IReadOnlyList<AdminKeyEntry> Find(string directory, IEnumerable<string>? rememberedPaths = null)
{
var paths = new List<string>();
try
{
if (Directory.Exists(directory))
paths.AddRange(Directory.EnumerateFiles(directory, "*" + AdminKeyFile.PrivateKeyExtension, SearchOption.TopDirectoryOnly)
.Concat(Directory.EnumerateFiles(directory, "*" + AdminTpmKeyFile.Extension, SearchOption.TopDirectoryOnly))
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// dossier illisible : seulement les chemins mémorisés
}
paths.AddRange(rememberedPaths ?? Enumerable.Empty<string>());
var result = new List<AdminKeyEntry>();
foreach (var path in paths)
{
// Describe normalise le chemin et ignore les chemins invalides (admin.json modifié à la main…).
if (Describe(path) is not { } entry) continue;
if (result.Any(e => string.Equals(e.Path, entry.Path, StringComparison.OrdinalIgnoreCase))) continue;
result.Add(entry);
}
return result;
}
/// <summary>Décrit un fichier de clé chiffré ; null s'il n'en est pas un (ou s'il est réservé).</summary>
public static AdminKeyEntry? Describe(string path)
{
try
{
var full = System.IO.Path.GetFullPath(path);
var info = new FileInfo(full);
if (!info.Exists || info.Length > MaxKeyFileBytes || IsReserved(full)) return null;
// Référence d'une clé TPM : empreinte calculée depuis la clé publique qu'elle contient
// (comparée à celle de la puce à la connexion).
if (AdminTpmKeyFile.IsTpmKeyPath(full))
{
try
{
var reference = AdminTpmKeyFile.Load(full);
return new AdminKeyEntry(full, AdminKeys.ComputeKeyId(reference.PublicKey),
AdminKeys.ComputeFingerprint(reference.PublicKey), IsTpm: true);
}
catch (System.Security.Cryptography.CryptographicException)
{
return null;
}
}
// Clé fichier : reconnue à son contenu (extension .key conseillée, mais pas exigée : admin-key.bat --out).
if (!File.ReadAllText(full).Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal)) return null;
string? keyId = null, fingerprint = null;
var publicPath = System.IO.Path.ChangeExtension(full, AdminKeyFile.PublicKeyExtension);
if (File.Exists(publicPath) && new FileInfo(publicPath).Length <= MaxKeyFileBytes)
{
try
{
var spki = AdminKeys.ParsePublicKey(File.ReadAllText(publicPath));
keyId = AdminKeys.ComputeKeyId(spki);
fingerprint = AdminKeys.ComputeFingerprint(spki);
}
catch (System.Security.Cryptography.CryptographicException)
{
// .pub illisible : empreinte inconnue (la clé reste utilisable)
}
}
return new AdminKeyEntry(full, keyId, fingerprint);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
return null;
}
}
/// <summary>
/// Emplacement libre pour une nouvelle clé : admin, sinon admin-2, admin-3… Le nom est libre pour
/// les deux types de clés (.key, .tpmkey) et pour le .pub qu'ils partageraient.
/// </summary>
/// <param name="tpm">Vrai : chemin d'un fichier de référence de clé TPM (.tpmkey).</param>
public static string SuggestNewKeyPath(string directory, bool tpm = false)
{
var extension = tpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
for (var i = 1; i < 1000; i++)
{
var name = System.IO.Path.Combine(directory, i == 1 ? "admin" : $"admin-{i}");
if (!File.Exists(name + AdminKeyFile.PrivateKeyExtension) && !File.Exists(name + AdminTpmKeyFile.Extension) &&
!File.Exists(name + AdminKeyFile.PublicKeyExtension))
return name + extension;
}
return System.IO.Path.Combine(directory, $"admin-{Guid.NewGuid():N}{extension}");
}
}
@@ -0,0 +1,73 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Connexion : ouvre la clé (fichier chiffré déverrouillé en arrière-plan, ou clé de la puce TPM avec
/// son code PIN), puis défi signé auprès du serveur.
/// </summary>
public sealed class AdminLoginService
{
private readonly LauncherApiClient _client;
private readonly string _clientName;
private readonly IAdminTpm? _tpm;
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
{
_client = client;
_clientName = clientName;
_tpm = tpm;
}
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
/// <param name="secret">Phrase de passe du fichier, ou code PIN de la clé TPM.</param>
public async Task<AdminConnection> LoginAsync(string keyPath, string secret, CancellationToken ct = default)
{
if (AdminKeyLocator.IsReserved(keyPath))
throw new InvalidOperationException("Ce fichier est la clé de signature du launcher : utilisez votre clé d'administrateur.");
keyPath = Path.GetFullPath(keyPath);
var isTpm = AdminTpmKeyFile.IsTpmKeyPath(keyPath);
if (string.IsNullOrEmpty(secret))
throw new ArgumentException(isTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.");
IAdminSigner signer;
IDisposable key;
if (isTpm)
{
var tpm = _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.");
var reference = AdminTpmKeyFile.Load(keyPath);
var opened = await BackgroundWork.RunAsync(() => tpm.OpenKey(reference.KeyName, reference.PublicKey, secret), ct)
.ConfigureAwait(false);
// Le fichier de référence ne doit pas pouvoir faire utiliser une AUTRE clé que celle annoncée
// (déjà vérifié par la puce avant le code PIN ; contrôlé de nouveau ici).
if (!opened.PublicKey.AsSpan().SequenceEqual(reference.PublicKey))
{
opened.Dispose();
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
}
signer = opened;
key = opened;
}
else
{
// Déchiffrement volontairement lent (PBKDF2, 600 000 itérations) : hors du fil de l'interface.
var opened = await BackgroundWork.RunAsync(() => new EcdsaAdminSigner(AdminKeyFile.Load(keyPath, secret)), ct).ConfigureAwait(false);
signer = opened;
key = opened;
}
try
{
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
return new AdminConnection(_client, session, signer, key, keyPath, isTpm);
}
catch
{
key.Dispose();
throw;
}
}
}
@@ -0,0 +1,129 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Surveille la session ouverte :
/// • verrouillage après <see cref="LockAfter"/> sans activité dans la fenêtre d'administration ;
/// • verrouillage à la fin de la durée maximale de la session (8 h) ;
/// • tant que l'administrateur est actif, la session est prolongée sur le serveur (toutes les
/// <see cref="KeepAliveEvery"/>) ; si le serveur la refuse (expirée, révoquée), verrouillage.
/// </summary>
public sealed class AdminSessionGuard : IDisposable
{
public static readonly TimeSpan DefaultLockAfter = TimeSpan.FromMinutes(10);
public static readonly TimeSpan KeepAliveEvery = TimeSpan.FromMinutes(4);
private static readonly TimeSpan TickEvery = TimeSpan.FromSeconds(15);
private readonly AdminConnection _connection;
private readonly TimeProvider _time;
private readonly ITimer? _timer;
private DateTimeOffset _lastActivity;
private DateTimeOffset _lastKeepAlive;
private bool _refreshing;
private bool _locked;
private bool _disposed;
public AdminSessionGuard(AdminConnection connection, TimeProvider time, IUiDispatcher ui, TimeSpan? lockAfter = null,
bool startTimer = true)
{
_connection = connection;
_time = time;
LockAfter = lockAfter ?? DefaultLockAfter;
_lastActivity = _lastKeepAlive = time.GetUtcNow();
if (startTimer) _timer = time.CreateTimer(_ => ui.Post(() => _ = TickAsync()), null, TickEvery, TickEvery);
}
public TimeSpan LockAfter { get; }
/// <summary>Verrouillage demandé (le message explique pourquoi) ; attendu par <see cref="TickAsync"/>.</summary>
public Func<string, Task>? LockRequested { get; set; }
/// <summary>Le temps restant a changé (affichage).</summary>
public event Action? Changed;
/// <summary>Moment du verrouillage prévu (inactivité ou fin de session).</summary>
public DateTimeOffset LockAt
{
get
{
var idle = _lastActivity + LockAfter;
return idle < _connection.LocalExpiresAt ? idle : _connection.LocalExpiresAt;
}
}
public TimeSpan Remaining
{
get
{
var remaining = LockAt - _time.GetUtcNow();
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
}
}
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
public void NotifyActivity()
{
var now = _time.GetUtcNow();
if (now - _lastActivity < TimeSpan.FromSeconds(1)) return;
_lastActivity = now;
}
/// <summary>Vérification périodique (minuterie) ; publique pour les tests.</summary>
public async Task TickAsync()
{
if (_locked || _disposed) return;
var now = _time.GetUtcNow();
if (now >= _connection.LocalExpiresAt)
{
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
return;
}
if (now - _lastActivity >= LockAfter)
{
await LockAsync($"Verrouillé après {LockAfter.TotalMinutes:0} minutes d'inactivité.");
return;
}
if (!_refreshing && now - _lastKeepAlive >= KeepAliveEvery && _lastActivity > _lastKeepAlive)
{
_refreshing = true;
try
{
await _connection.Session.RefreshAsync();
_lastKeepAlive = _time.GetUtcNow();
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
await LockAsync("Session expirée ou révoquée sur le serveur : reconnectez-vous.");
return;
}
catch (Exception ex)
{
// Serveur momentanément injoignable, délai dépassé… : nouvel essai au prochain passage.
// L'inactivité et la durée maximale restent contrôlées localement.
Log.Warn($"Administration : prolongation de la session impossible ({ex.GetType().Name}).");
}
finally
{
_refreshing = false;
}
}
Changed?.Invoke();
}
private async Task LockAsync(string reason)
{
if (_locked) return;
_locked = true;
if (LockRequested is { } handler) await handler(reason);
}
public void Dispose()
{
_disposed = true;
_timer?.Dispose();
}
}
@@ -0,0 +1,64 @@
using System.Text.Json;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Préférences du menu d'administration sur ce PC (%AppData%\…\GameLauncher\admin.json) : chemins
/// des fichiers de clé utilisés. Aucun secret (les clés restent chiffrées dans leurs fichiers).
/// </summary>
public sealed class AdminSettings
{
public const string FileName = "admin.json";
private const int MaxRememberedKeys = 10;
public string? LastKeyPath { get; set; }
public List<string> KeyPaths { get; set; } = new();
public static AdminSettings Load(string path)
{
try
{
if (!File.Exists(path) || new FileInfo(path).Length > 64 * 1024) return new AdminSettings();
var settings = JsonSerializer.Deserialize<AdminSettings>(File.ReadAllText(path), JsonDefaults.Options) ?? new AdminSettings();
settings.KeyPaths = (settings.KeyPaths ?? new()).Where(p => !string.IsNullOrWhiteSpace(p)).Take(MaxRememberedKeys).ToList();
return settings;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"Préférences d'administration illisibles ({path}) : {ex.Message}");
return new AdminSettings();
}
}
/// <summary>Oublie une clé (supprimée).</summary>
public void Forget(string keyPath)
{
KeyPaths.RemoveAll(p => string.Equals(p, keyPath, StringComparison.OrdinalIgnoreCase));
if (string.Equals(LastKeyPath, keyPath, StringComparison.OrdinalIgnoreCase)) LastKeyPath = null;
}
/// <summary>Mémorise la clé utilisée (en tête de liste).</summary>
public void Remember(string keyPath)
{
var full = Path.GetFullPath(keyPath);
LastKeyPath = full;
KeyPaths.RemoveAll(p => string.Equals(p, full, StringComparison.OrdinalIgnoreCase));
KeyPaths.Insert(0, full);
if (KeyPaths.Count > MaxRememberedKeys) KeyPaths.RemoveRange(MaxRememberedKeys, KeyPaths.Count - MaxRememberedKeys);
}
public void Save(string path)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
FileUtil.WriteAllTextAtomic(path, JsonSerializer.Serialize(this, JsonDefaults.Options));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Préférences d'administration non enregistrées ({path}) : {ex.Message}");
}
}
}
@@ -0,0 +1,35 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Libellés affichés (français).</summary>
public static class AdminTexts
{
public static string Permission(string permission) => permission switch
{
AdminPermissions.AdminsManage => "Gestion des administrateurs",
AdminPermissions.AuditRead => "Journal d'audit",
AdminPermissions.GamesEdit => "Informations des jeux",
AdminPermissions.ChangelogEdit => "Notes de version",
AdminPermissions.ReleasesPublish => "Publication des versions",
_ => permission
};
public static string Role(string? role) => AdminRoles.DisplayName(role);
/// <summary>Initiales pour l'avatar : « Nicolas Deville » → « ND ».</summary>
public static string Initials(string? name)
{
var parts = (name ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var initials = string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
return initials.Length == 0 ? "?" : initials;
}
/// <summary>Durée lisible : « 9 min », « moins d'une minute », « 2 h 05 ».</summary>
public static string Duration(TimeSpan duration)
{
if (duration < TimeSpan.FromMinutes(1)) return "moins d'une minute";
if (duration < TimeSpan.FromHours(1)) return $"{(int)duration.TotalMinutes} min";
return $"{(int)duration.TotalHours} h {duration.Minutes:00}";
}
}
@@ -0,0 +1,132 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Clé conservée dans la puce TPM, telle que décrite par son fichier de référence.</summary>
/// <param name="KeyName">Nom de la clé dans la puce de ce PC.</param>
/// <param name="PublicKey">Clé publique (SPKI DER) attendue : comparée à celle de la puce à l'ouverture.</param>
public sealed record AdminTpmKeyReference(string KeyName, byte[] PublicKey);
/// <summary>
/// Fichier de référence d'une clé TPM (« admin.tpmkey ») : il ne contient AUCUN secret, seulement le
/// nom de la clé dans la puce et sa clé publique. Copié sur un autre PC, il ne sert à rien. Un fichier
/// .pub est écrit à côté, comme pour une clé fichier (déclaration du premier propriétaire : add-owner).
/// </summary>
public static partial class AdminTpmKeyFile
{
public const string Extension = ".tpmkey";
/// <summary>
/// Longueur minimale du code PIN. Plus courte que la phrase de passe d'un fichier (12) : le code
/// ne peut être essayé que sur la puce elle-même, qui se bloque après quelques erreurs.
/// </summary>
public const int MinPinLength = 6;
/// <summary>Préfixe des noms de clés créées par le launcher (seules ces clés peuvent être ouvertes).</summary>
public const string KeyNamePrefix = "GameLauncher.Admin.";
private const string Format = "gamelauncher-admin-tpm-key";
private const int MaxFileBytes = 16 * 1024;
[GeneratedRegex(@"\AGameLauncher\.Admin\.[0-9a-f]{32}\z")]
private static partial Regex KeyNameRegex();
public static string NewKeyName() => KeyNamePrefix + Guid.NewGuid().ToString("N");
public static bool IsValidKeyName(string? keyName) => keyName is not null && KeyNameRegex().IsMatch(keyName);
public static bool IsTpmKeyPath(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
/// <summary>Écrit le fichier de référence et le .pub, sans jamais écraser un fichier existant.</summary>
/// <returns>Chemin du fichier .pub.</returns>
public static string Save(string path, string keyName, byte[] publicKey)
{
if (!IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
AdminKeys.ParsePublicKey(Convert.ToBase64String(publicKey)); // P-256 attendue
if (!IsTpmKeyPath(path)) throw new ArgumentException($"Le fichier de référence doit avoir l'extension {Extension}.");
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var json = new JsonObject
{
["format"] = Format,
["version"] = 1,
["provider"] = "Microsoft Platform Crypto Provider",
["keyName"] = keyName,
["publicKey"] = Convert.ToBase64String(publicKey)
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
WriteNew(path, json);
var publicPath = Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension);
try
{
WriteNew(publicPath, AdminKeys.ToPem(publicKey));
}
catch
{
TryDelete(path);
throw;
}
return publicPath;
}
/// <summary>Lit et contrôle un fichier de référence (<see cref="CryptographicException"/> s'il est invalide).</summary>
public static AdminTpmKeyReference Load(string path)
{
var info = new FileInfo(path);
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
if (info.Length > MaxFileBytes) throw new CryptographicException("Fichier de clé TPM inattendu (trop volumineux).");
try
{
var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject
?? throw new CryptographicException("Fichier de clé TPM illisible.");
if (root["format"]?.GetValue<string>() != Format || root["version"]?.GetValue<int>() != 1)
throw new CryptographicException("Ce fichier n'est pas une clé TPM du launcher.");
var keyName = root["keyName"]?.GetValue<string>();
if (!IsValidKeyName(keyName)) throw new CryptographicException("Fichier de clé TPM invalide (nom de clé).");
var publicKey = AdminKeys.ParsePublicKey(root["publicKey"]?.GetValue<string>() ?? "");
return new AdminTpmKeyReference(keyName!, publicKey);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or ArgumentException)
{
throw new CryptographicException("Fichier de clé TPM illisible.", ex);
}
}
/// <summary>Supprime le fichier de référence et son .pub (clé refusée, création annulée, suppression demandée).</summary>
/// <returns>Les fichiers qui n'ont pas pu être supprimés (à supprimer à la main).</returns>
internal static IReadOnlyList<string> DeleteFiles(string path)
{
var left = new List<string>();
foreach (var file in new[] { path, Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension) })
if (!TryDelete(file)) left.Add(file);
return left;
}
private static void WriteNew(string path, string text)
{
using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
using var writer = new StreamWriter(stream);
writer.Write(text);
}
private static bool TryDelete(string path)
{
try
{
File.Delete(path);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return false; // laissé en place : sans danger (aucun secret dans ces fichiers)
}
}
}
@@ -0,0 +1,32 @@
using System.Runtime.ExceptionServices;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>
/// Travail lent (chiffrement de clé) hors du fil de l'interface. L'erreur éventuelle est capturée
/// DANS le travail puis relancée ici, dans la méthode asynchrone appelante : une erreur attendue
/// (phrase de passe incorrecte…) ne traverse pas Task.Run, ce qui évite que le débogueur de Visual
/// Studio ne s'arrête sur une « exception non gérée par l'utilisateur » alors qu'elle est bien gérée
/// (message affiché dans le menu d'administration).
/// </summary>
internal static class BackgroundWork
{
public static async Task<T> RunAsync<T>(Func<T> work, CancellationToken ct = default)
{
ExceptionDispatchInfo? failure = null;
var result = await Task.Run(() =>
{
try
{
return work();
}
catch (Exception ex)
{
failure = ExceptionDispatchInfo.Capture(ex);
return default;
}
}, ct).ConfigureAwait(false);
failure?.Throw();
return result!;
}
}
@@ -0,0 +1,83 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
namespace GameLauncher.Modules.Admin.Services;
/// <summary>Résultat de la vérification de la puce de sécurité (TPM) de ce PC.</summary>
/// <param name="IsAvailable">Vrai si une clé ECDSA P-256 a pu être créée, utilisée puis supprimée dans la puce.</param>
/// <param name="Message">Explication affichée (disponible, ou raison de l'indisponibilité).</param>
public sealed record TpmAvailability(bool IsAvailable, string Message)
{
public static readonly TpmAvailability Unsupported =
new(false, "Puce de sécurité (TPM) non prise en charge par ce launcher sur ce système.");
}
/// <summary>
/// Protection anti-essais de la puce (compteur COMMUN à toute la puce : codes PIN du launcher, de
/// Windows Hello…). Au-delà de <see cref="MaxTries"/> erreurs, la puce refuse tout code ; elle
/// « oublie » une erreur à chaque <see cref="RecoveryInterval"/> écoulé, PC allumé.
/// </summary>
public sealed record TpmLockoutInfo(int Failures, int MaxTries, TimeSpan RecoveryInterval, bool InLockout)
{
public bool IsLockedOut => InLockout || (MaxTries > 0 && Failures >= MaxTries);
/// <summary>Codes faux encore permis avant le blocage.</summary>
public int RemainingTries => Math.Max(0, MaxTries - Failures);
/// <summary>Faux : la puce ne se débloque pas seule (un administrateur du PC doit la réinitialiser).</summary>
public bool HealsWithTime => RecoveryInterval > TimeSpan.Zero;
/// <summary>Attente MAXIMALE avant que la puce accepte de nouveau un code (estimation, PC allumé).</summary>
public TimeSpan MaxWait => !IsLockedOut || !HealsWithTime
? TimeSpan.Zero
: RecoveryInterval * Math.Max(1, Failures - MaxTries + 1);
}
/// <summary>Code PIN refusé par la puce, ou puce bloquée par sa protection anti-essais.</summary>
public sealed class TpmPinException : CryptographicException
{
public TpmPinException(string message, bool isLockedOut, Exception? inner = null) : base(message, inner) =>
IsLockedOut = isLockedOut;
public bool IsLockedOut { get; }
}
/// <summary>Clé ouverte (dans la puce ou en mémoire) : elle signe, et se libère à la fermeture de la session.</summary>
public interface IDisposableAdminSigner : IAdminSigner, IDisposable
{
}
/// <summary>
/// Clés d'administrateur conservées dans la puce de sécurité (TPM 2.0) de ce PC : la clé privée est
/// CRÉÉE dans la puce et n'en sort jamais (non exportable) ; la puce signe elle-même. Chaque usage
/// exige le code PIN de la clé, vérifié par la puce (blocage temporaire après plusieurs erreurs).
/// Implémentation Windows dans le launcher (Microsoft Platform Crypto Provider) ; les méthodes sont
/// lentes (accès à la puce) et sont appelées hors du fil de l'interface.
/// </summary>
public interface IAdminTpm
{
/// <summary>Vérifie RÉELLEMENT la puce : création, signature et suppression d'une clé de test.</summary>
TpmAvailability Check();
/// <summary>Crée une clé (non exportable) protégée par <paramref name="pin"/> et l'ouvre.</summary>
/// <param name="keyName">Nom de la clé dans la puce (<see cref="AdminTpmKeyFile.NewKeyName"/>).</param>
IDisposableAdminSigner CreateKey(string keyName, string pin);
/// <summary>
/// Ouvre une clé existante, vérifie qu'elle a bien la clé publique attendue (AVANT tout essai du
/// code, qui consommerait un essai de la protection anti-essais), puis vérifie le code PIN
/// (signature de test). <see cref="System.Security.Cryptography.CryptographicException"/> avec un
/// message clair si la clé diffère, si le code est faux, si la puce est bloquée ou si la clé
/// n'existe pas sur ce PC.
/// </summary>
IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin);
/// <summary>Supprime la clé de la puce (sans erreur si elle n'existe plus).</summary>
void DeleteKey(string keyName);
/// <summary>
/// État de la protection anti-essais (lu dans la puce sans rien modifier), ou null s'il n'est pas
/// lisible sur ce PC (Windows peut réserver cette lecture aux administrateurs).
/// </summary>
TpmLockoutInfo? GetLockoutInfo();
}
@@ -0,0 +1,42 @@
using GameLauncher.ApiClient;
using GameLauncher.Core.Models;
using GameLauncher.Modules.Admin.Services;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>Dépendances de la fenêtre d'administration (fournies par le launcher).</summary>
public sealed class AdminEnvironment
{
/// <summary>Client de l'API (adresse intégrée à l'exe).</summary>
public required LauncherApiClient Client { get; init; }
public required ModuleCatalog Modules { get; init; }
public required IReadOnlyList<LauncherConfig> Games { get; init; }
public required IModuleDialogs Dialogs { get; init; }
public required IUiDispatcher Ui { get; init; }
/// <summary>Fichier des préférences d'administration (%AppData%\…\GameLauncher\admin.json).</summary>
public required string SettingsPath { get; init; }
public required string LauncherVersion { get; init; }
public TimeProvider Time { get; init; } = TimeProvider.System;
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
public IAdminTpm? Tpm { get; init; }
/// <summary>Dossier des clés proposé par défaut (%USERPROFILE%\.launcher-keys).</summary>
public string KeysDirectory { get; init; } = AdminKeyLocator.DefaultDirectory;
/// <summary>Verrouillage après inactivité (10 min par défaut).</summary>
public TimeSpan? LockAfter { get; init; }
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
public bool StartTimers { get; init; } = true;
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
}
@@ -0,0 +1,136 @@
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>
/// Fenêtre d'administration : un seul écran à la fois — connexion (<see cref="LoginViewModel"/>),
/// création de clé / invitation (<see cref="EnrollViewModel"/>) ou espace d'administration
/// (<see cref="AdminWorkspaceViewModel"/>). Verrouiller ferme la session et libère la clé.
/// </summary>
public sealed class AdminShellViewModel : ObservableObject, IAsyncDisposable
{
private object _screen;
private AdminWorkspaceViewModel? _workspace;
private bool _disposed;
private Task<TpmAvailability>? _tpmCheck;
public AdminShellViewModel(AdminEnvironment environment)
{
Environment = environment;
Settings = AdminSettings.Load(environment.SettingsPath);
_screen = new LoginViewModel(this, null, null);
}
public AdminEnvironment Environment { get; }
internal AdminSettings Settings { get; }
/// <summary>Écran affiché (le launcher choisit la vue selon son type).</summary>
public object Screen
{
get => _screen;
private set
{
var previous = _screen;
if (!SetProperty(ref _screen, value)) return;
OnPropertyChanged(nameof(IsLoggedIn));
if (previous is LoginViewModel login) login.Dispose(); // arrêt de ses minuteries (décompte de la puce)
}
}
public bool IsLoggedIn => Screen is AdminWorkspaceViewModel;
public string ServerHost => Environment.Client.BaseUrl.Host;
public string WindowTitle => $"Administration — {ServerHost}";
/// <summary>Souris ou clavier dans la fenêtre : repousse le verrouillage automatique.</summary>
public void NotifyActivity() => _workspace?.Guard.NotifyActivity();
public void ShowLogin(string? status = null, string? selectKeyPath = null)
{
if (_disposed) return;
Screen = new LoginViewModel(this, status, selectKeyPath);
}
/// <summary>Vérification de la puce TPM (une fois par ouverture de la fenêtre, hors du fil de l'interface).</summary>
internal Task<TpmAvailability> CheckTpmAsync() => _tpmCheck ??= RunTpmCheckAsync();
private async Task<TpmAvailability> RunTpmCheckAsync()
{
if (Environment.Tpm is not { } tpm) return TpmAvailability.Unsupported;
try
{
var availability = await BackgroundWork.RunAsync(tpm.Check);
Log.Info($"Administration : puce TPM {(availability.IsAvailable ? "disponible" : "indisponible")} ({availability.Message})");
return availability;
}
catch (Exception ex)
{
Log.Warn($"Administration : vérification de la puce TPM impossible ({ex.Message}).");
return new TpmAvailability(false, $"Puce de sécurité (TPM) inutilisable : {ex.Message}");
}
}
public void ShowEnroll()
{
if (_disposed) return;
Screen = new EnrollViewModel(this);
}
internal async Task OpenWorkspaceAsync(AdminConnection connection)
{
if (_disposed)
{
await connection.DisposeAsync();
return;
}
AdminWorkspaceViewModel workspace;
try
{
workspace = new AdminWorkspaceViewModel(this, connection);
}
catch
{
await connection.DisposeAsync(); // clé effacée, session fermée
throw;
}
_workspace = workspace;
Screen = workspace;
await workspace.StartAsync();
}
/// <summary>Ferme la session (serveur + clé en mémoire) et revient à l'écran de connexion.</summary>
public Task LockAsync(string? reason) => _workspace is { } workspace ? LockAsync(workspace, reason) : Task.CompletedTask;
/// <summary>
/// Verrouillage demandé par un espace donné (minuterie, session perdue) : sans effet s'il n'est plus
/// l'espace courant — une requête tardive d'une session fermée ne doit pas verrouiller la suivante.
/// </summary>
internal async Task LockAsync(AdminWorkspaceViewModel source, string? reason)
{
if (!ReferenceEquals(_workspace, source)) return;
_workspace = null;
try
{
if (!_disposed) ShowLogin(reason, source.KeyPath);
}
finally
{
await source.DisposeAsync(); // dans tous les cas : clé effacée, session fermée
}
}
/// <summary>Fermeture de la fenêtre : déconnexion.</summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
(_screen as LoginViewModel)?.Dispose();
var workspace = _workspace;
_workspace = null;
if (workspace is not null) await workspace.DisposeAsync();
}
}
@@ -0,0 +1,144 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>Page du menu d'administration dans la navigation (ViewModel créé à la première ouverture).</summary>
public sealed class AdminPanelItem : ObservableObject
{
private AdminPanelViewModel? _viewModel;
public AdminPanelItem(AdminPanelDescriptor descriptor) => Descriptor = descriptor;
public AdminPanelDescriptor Descriptor { get; }
public string Title => Descriptor.Title;
public string Description => Descriptor.Description;
public string IconKey => Descriptor.IconKey;
public AdminPanelViewModel? ViewModel
{
get => _viewModel;
internal set => SetProperty(ref _viewModel, value);
}
}
/// <summary>
/// Espace d'administration (connecté) : navigation entre les pages des modules autorisées par les
/// permissions, identité, temps restant avant verrouillage, bouton « Verrouiller ».
/// </summary>
public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, IAsyncDisposable
{
private readonly AdminShellViewModel _shell;
private readonly AdminConnection _connection;
private AdminPanelItem? _selectedPanel;
private Task _activation = Task.CompletedTask;
private bool _disposed;
internal AdminWorkspaceViewModel(AdminShellViewModel shell, AdminConnection connection)
{
_shell = shell;
_connection = connection;
var environment = shell.Environment;
Guard = new AdminSessionGuard(connection, environment.Time, environment.Ui, environment.LockAfter, environment.StartTimers);
Guard.LockRequested = reason => shell.LockAsync(this, reason);
Guard.Changed += () => OnPropertyChanged(nameof(SessionText));
Panels = environment.Modules.AdminPanelsFor(Session.Profile.Permissions);
PanelItems = new ObservableCollection<AdminPanelItem>(Panels.Select(p => new AdminPanelItem(p)));
LockCommand = new AsyncRelayCommand(() => shell.LockAsync(this, "Session verrouillée."));
}
internal AdminSessionGuard Guard { get; }
internal string KeyPath => _connection.KeyPath;
// ------------------------------------------------------------------ Affichage
public ObservableCollection<AdminPanelItem> PanelItems { get; }
public AdminPanelItem? SelectedPanel
{
get => _selectedPanel;
set
{
if (value is null || ReferenceEquals(value, _selectedPanel)) return;
_selectedPanel = value;
OnPropertyChanged();
OnPropertyChanged(nameof(PanelTitle));
OnPropertyChanged(nameof(PanelDescription));
_activation = ActivateAsync(value);
}
}
public string PanelTitle => SelectedPanel?.Title ?? "";
public string PanelDescription => SelectedPanel?.Description ?? "";
public string DisplayName => Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(Session.Profile.DisplayName);
public string RoleText => AdminTexts.Role(Session.Profile.Role);
public string KeyLabel => (Session.Profile.KeyLabel ?? Path.GetFileNameWithoutExtension(_connection.KeyPath)) +
(_connection.IsTpm ? " · puce TPM" : "");
public string ServerHost => _shell.ServerHost;
/// <summary>« Verrouillage dans 9 min ».</summary>
public string SessionText => $"Verrouillage dans {AdminTexts.Duration(Guard.Remaining)}";
public AsyncRelayCommand LockCommand { get; }
/// <summary>Ouvre la première page et attend son chargement initial.</summary>
internal Task StartAsync()
{
SelectedPanel = PanelItems.FirstOrDefault();
return _activation;
}
/// <summary>Chargement de la page affichée (tests).</summary>
internal Task Activation => _activation;
private async Task ActivateAsync(AdminPanelItem item)
{
if (_disposed) return;
try
{
item.ViewModel ??= item.Descriptor.CreateViewModel(this);
await item.ViewModel.ActivateAsync();
}
catch (Exception ex)
{
Log.Error($"Administration : ouverture de la page {item.Descriptor.Id}", ex);
Dialogs.ShowMessage("Page indisponible", ApiErrorText.Describe(ex), ModuleMessageKind.Error);
}
}
// ------------------------------------------------------------------ IAdminContext
public AdminSession Session => _connection.Session;
public LauncherApiClient Client => _connection.Client;
public IReadOnlyList<LauncherConfig> Games => _shell.Environment.Games;
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public void Navigate(string panelId)
{
if (PanelItems.FirstOrDefault(p => string.Equals(p.Descriptor.Id, panelId, StringComparison.OrdinalIgnoreCase)) is { } item)
SelectedPanel = item;
}
public void ReportSessionLost(string reason) => _ = _shell.LockAsync(this, reason);
// ------------------------------------------------------------------ Fermeture
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
Guard.Dispose();
await _connection.DisposeAsync();
}
}
@@ -0,0 +1,124 @@
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>Jeu déclaré dans launcher.json, tel qu'affiché au tableau de bord.</summary>
public sealed record DashboardGame(string Name, string GameId, string ManifestHost);
/// <summary>
/// Page d'accueil : compte connecté et permissions, session, serveur, jeux déclarés et, pour les
/// propriétaires, les clés en attente d'approbation.
/// </summary>
public sealed class DashboardPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "admin.dashboard";
private string _serverVersion = "…";
private string _serverId = "…";
private int _pendingApprovals;
private int _activeInvitations;
private string? _lastRefresh;
public DashboardPanelViewModel(IAdminContext context) : base(context)
{
var profile = context.Session.Profile;
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
}
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
public IReadOnlyList<string> Permissions { get; }
public string KeyText => $"{Context.Session.Profile.KeyLabel ?? "Clé"} · {Context.Session.Profile.KeyId}";
/// <summary>« Session ouverte jusqu'à 23:14 au plus tard » (heure du PC).</summary>
public string SessionText =>
$"Ouverte au plus tard jusqu'à {(Context.Session.ExpiresAt - Context.Session.ClockOffset).ToLocalTime():HH:mm}";
public string ServerOrigin => Context.Client.Origin;
public string ServerVersion
{
get => _serverVersion;
private set => SetProperty(ref _serverVersion, value);
}
/// <summary>Identifiant du serveur (début), pour le support.</summary>
public string ServerId
{
get => _serverId;
private set => SetProperty(ref _serverId, value);
}
public string LauncherVersion => Context.LauncherVersion;
public IReadOnlyList<DashboardGame> Games { get; }
public bool CanManageAdmins => Context.Session.Has(AdminPermissions.AdminsManage);
public int PendingApprovals
{
get => _pendingApprovals;
private set
{
if (SetProperty(ref _pendingApprovals, value))
{
OnPropertyChanged(nameof(HasPendingApprovals));
OnPropertyChanged(nameof(PendingText));
}
}
}
public int ActiveInvitations
{
get => _activeInvitations;
private set
{
if (SetProperty(ref _activeInvitations, value)) OnPropertyChanged(nameof(PendingText));
}
}
public bool HasPendingApprovals => PendingApprovals > 0;
public string PendingText => PendingApprovals switch
{
0 => ActiveInvitations == 0 ? "Aucune clé en attente, aucune invitation en cours." : $"Aucune clé en attente · {ActiveInvitations} invitation(s) en cours.",
1 => $"1 clé attend votre approbation · {ActiveInvitations} invitation(s) en cours.",
var n => $"{n} clés attendent votre approbation · {ActiveInvitations} invitation(s) en cours."
};
public string? LastRefresh
{
get => _lastRefresh;
private set => SetProperty(ref _lastRefresh, value);
}
public AsyncRelayCommand RefreshCommand { get; }
protected override void OnBusyChanged() => RefreshCommand.RaiseCanExecuteChanged();
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshAsync() : Task.CompletedTask;
public Task RefreshAsync() => RunAsync(async () =>
{
var info = await Context.Client.GetInfoAsync();
ServerVersion = info.ServerVersion;
ServerId = info.ServerId.Length > 8 ? info.ServerId[..8] : info.ServerId;
if (CanManageAdmins)
{
var admins = await Context.Session.ListAdminsAsync();
var invitations = await Context.Session.ListInvitationsAsync();
PendingApprovals = admins.Where(a => a.Status != AdminStatus.Revoked)
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
ActiveInvitations = invitations.Count;
}
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
});
}
@@ -0,0 +1,266 @@
using GameLauncher.ApiClient;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>
/// « J'ai une invitation » : création d'une clé sur ce PC et, avec un code d'invitation,
/// enregistrement auprès du serveur. Deux rangements au choix :
/// • fichier chiffré par une phrase de passe (copiable, sauvegardable) — par défaut ;
/// • puce de sécurité (TPM) de ce PC, protégée par un code PIN (non copiable) — proposé seulement
/// une fois la puce RÉELLEMENT vérifiée (création, signature et suppression d'une clé de test).
/// Affiche ensuite l'EMPREINTE à communiquer à la personne qui approuve. Sans code : clé seule
/// (premier propriétaire, à déclarer sur le VPS).
/// </summary>
public sealed class EnrollViewModel : ObservableObject
{
private readonly AdminShellViewModel _shell;
private string _invitationCode = "";
private string _keyLabel;
private string _keyPath;
private bool _useTpm;
private bool _isBusy;
private string? _errorMessage;
private AdminKeyCreation? _result;
private TpmAvailability? _tpm;
public EnrollViewModel(AdminShellViewModel shell)
{
_shell = shell;
_keyLabel = System.Environment.MachineName;
_keyPath = AdminKeyLocator.SuggestNewKeyPath(shell.Environment.KeysDirectory);
BackCommand = new RelayCommand(() => shell.ShowLogin(), () => !IsBusy);
ChangePathCommand = new RelayCommand(ChangePath, () => !IsBusy && !IsDone);
CopyFingerprintCommand = new RelayCommand(() =>
{
if (_result is not null) shell.Environment.Dialogs.CopyToClipboard(_result.Fingerprint);
});
OpenKeyFolderCommand = new RelayCommand(() =>
{
if (Path.GetDirectoryName(_result?.KeyPath ?? KeyPath) is { } folder && Directory.Exists(folder))
shell.Environment.Dialogs.OpenFolder(folder);
});
ContinueCommand = new RelayCommand(() => shell.ShowLogin(
_result is null or { Enrollment: null, EnrollmentUnconfirmed: null } ? null : "Connectez-vous dès que votre clé a été approuvée.",
_result?.KeyPath));
TpmCheck = CheckTpmAsync();
}
public string InvitationCode
{
get => _invitationCode;
set => SetProperty(ref _invitationCode, value ?? "");
}
/// <summary>Nom de ce PC, visible par les propriétaires (ex. « PC bureau »).</summary>
public string KeyLabel
{
get => _keyLabel;
set => SetProperty(ref _keyLabel, value ?? "");
}
public string KeyPath
{
get => _keyPath;
private set => SetProperty(ref _keyPath, value);
}
// ------------------------------------------------------------------ Rangement de la clé
/// <summary>Vérification de la puce en cours (le choix « puce » reste grisé).</summary>
public bool IsCheckingTpm => _tpm is null;
/// <summary>Puce vérifiée et utilisable.</summary>
public bool IsTpmAvailable => _tpm?.IsAvailable == true;
/// <summary>« Vérification de la puce de sécurité… », « Puce TPM 2.0 disponible… » ou la raison de l'indisponibilité.</summary>
public string TpmStatus => _tpm?.Message ?? "Vérification de la puce de sécurité (TPM) de ce PC…";
/// <summary>Le choix « puce de sécurité » n'est dégrisé qu'après une vérification réussie.</summary>
public bool CanChooseTpm => IsTpmAvailable && !IsBusy && !IsDone;
public bool CanChooseFile => !IsBusy && !IsDone;
/// <summary>Faux : fichier chiffré (par défaut). Vrai : puce de sécurité (TPM) de ce PC.</summary>
public bool UseTpm
{
get => _useTpm;
set
{
if ((value && !IsTpmAvailable) || IsBusy || IsDone || value == _useTpm)
{
// Refusé (jamais la puce sans vérification réussie) : l'affichage revient à l'état réel.
OnPropertyChanged(nameof(UseTpm));
OnPropertyChanged(nameof(UseFile));
return;
}
_useTpm = value;
ErrorMessage = null;
KeyPath = AdminKeyLocator.SuggestNewKeyPath(Path.GetDirectoryName(KeyPath) ?? _shell.Environment.KeysDirectory, value);
foreach (var name in new[] { nameof(UseTpm), nameof(UseFile), nameof(MinSecretLength), nameof(SecretLabel),
nameof(ConfirmationLabel), nameof(SecretHint), nameof(KeyFileLabel) })
OnPropertyChanged(name);
}
}
public bool UseFile
{
get => !_useTpm;
set
{
if (value) UseTpm = false;
}
}
public int MinSecretLength => UseTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
public string SecretLabel => UseTpm
? $"Code PIN de la clé (au moins {AdminTpmKeyFile.MinPinLength} caractères)"
: $"Phrase de passe (au moins {AdminKeyFile.MinPassphraseLength} caractères)";
public string ConfirmationLabel => UseTpm ? "Confirmation du code PIN" : "Confirmation";
public string SecretHint => UseTpm
? "Le code PIN est vérifié par la puce elle-même, qui se bloque temporairement après plusieurs erreurs " +
"(ces erreurs comptent aussi pour les autres usages de la puce, comme le code PIN Windows Hello). " +
"Cette clé ne peut être ni copiée ni sauvegardée : gardez aussi une clé « fichier » de secours."
: "Une phrase de plusieurs mots est plus sûre et plus facile à retenir. Elle ne peut pas être récupérée : " +
"en cas d'oubli, il faudra une nouvelle invitation.";
public string KeyFileLabel => UseTpm
? "Fichier de référence (aucun secret : la clé reste dans la puce)"
: "Fichier de la clé";
// ------------------------------------------------------------------ État
public bool IsBusy
{
get => _isBusy;
private set
{
if (!SetProperty(ref _isBusy, value)) return;
BackCommand.RaiseCanExecuteChanged();
ChangePathCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(CanChooseTpm));
OnPropertyChanged(nameof(CanChooseFile));
}
}
public string? ErrorMessage
{
get => _errorMessage;
private set
{
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool IsDone => _result is not null;
public bool IsForm => _result is null;
/// <summary>Clé enregistrée auprès du serveur (sinon : clé seule).</summary>
public bool IsEnrolled => _result?.Enrollment is not null;
public bool IsKeyOnly => _result is { Enrollment: null, EnrollmentUnconfirmed: null };
/// <summary>Invitation envoyée sans réponse sûre du serveur : clé conservée, marche à suivre affichée.</summary>
public bool IsUnconfirmed => _result?.EnrollmentUnconfirmed is not null;
public string UnconfirmedText => _result?.EnrollmentUnconfirmed ?? "";
/// <summary>Clé créée dans la puce de sécurité (TPM).</summary>
public bool IsTpmKey => _result?.IsTpm == true;
public string Fingerprint => _result?.Fingerprint ?? "";
public string CreatedKeyPath => _result?.KeyPath ?? "";
public string PublicKeyPath => _result?.PublicKeyPath ?? "";
public string AccountName => _result?.Enrollment?.DisplayName ?? "";
public RelayCommand BackCommand { get; }
public RelayCommand ChangePathCommand { get; }
public RelayCommand CopyFingerprintCommand { get; }
public RelayCommand OpenKeyFolderCommand { get; }
public RelayCommand ContinueCommand { get; }
/// <summary>Vérification de la puce lancée à l'ouverture de l'écran (tests).</summary>
internal Task TpmCheck { get; }
/// <summary>Crée la clé (et l'enregistre si un code est saisi). La vue efface les secrets après l'appel.</summary>
public async Task SubmitAsync(string secret, string confirmation)
{
if (IsBusy || IsDone) return;
if (UseTpm && !IsTpmAvailable)
{
ErrorMessage = "La puce de sécurité (TPM) n'est pas utilisable sur ce PC : choisissez « Fichier chiffré ».";
return;
}
if (secret.Length < MinSecretLength)
{
ErrorMessage = UseTpm
? $"Le code PIN doit contenir au moins {MinSecretLength} caractères."
: $"La phrase de passe doit contenir au moins {MinSecretLength} caractères.";
return;
}
if (!string.Equals(secret, confirmation, StringComparison.Ordinal))
{
ErrorMessage = UseTpm ? "Les deux codes PIN sont différents." : "Les deux phrases de passe sont différentes.";
return;
}
IsBusy = true;
ErrorMessage = null;
try
{
var result = await new AdminEnrollmentService(_shell.Environment.Client, _shell.Environment.Tpm)
.CreateKeyAsync(KeyPath, secret, InvitationCode, KeyLabel, useTpm: UseTpm);
_shell.Settings.Remember(result.KeyPath);
_shell.Settings.Save(_shell.Environment.SettingsPath);
var storage = result.IsTpm ? "puce TPM" : "fichier";
Log.Info(result switch
{
{ Enrollment: { } enrollment } =>
$"Administration : clé {result.KeyId} ({storage}) enregistrée pour {enrollment.DisplayName}, en attente d'approbation.",
{ EnrollmentUnconfirmed: not null } => $"Administration : clé {result.KeyId} ({storage}) créée, enregistrement non confirmé par le serveur.",
_ => $"Administration : clé créée ({result.KeyId}, {storage})."
});
_result = result;
foreach (var name in new[] { nameof(IsDone), nameof(IsForm), nameof(IsEnrolled), nameof(IsKeyOnly), nameof(IsUnconfirmed),
nameof(UnconfirmedText), nameof(IsTpmKey), nameof(Fingerprint), nameof(CreatedKeyPath),
nameof(PublicKeyPath), nameof(AccountName), nameof(CanChooseTpm), nameof(CanChooseFile) })
OnPropertyChanged(name);
ChangePathCommand.RaiseCanExecuteChanged();
}
catch (Exception ex)
{
ErrorMessage = ApiErrorText.Describe(ex);
if (ex is ApiException api) Log.Warn($"Administration : enregistrement refusé ({api.Status} {api.Code}).");
else if (UseTpm) Log.Warn($"Administration : création de la clé TPM impossible ({ex.Message}).");
}
finally
{
IsBusy = false;
}
}
private async Task CheckTpmAsync()
{
var availability = await _shell.CheckTpmAsync();
_tpm = availability;
foreach (var name in new[] { nameof(IsCheckingTpm), nameof(IsTpmAvailable), nameof(TpmStatus), nameof(CanChooseTpm) })
OnPropertyChanged(name);
}
private void ChangePath()
{
var directory = Path.GetDirectoryName(KeyPath);
var extension = UseTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
var filter = UseTpm ? "Référence de clé TPM (*.tpmkey)|*.tpmkey" : "Clé d'administrateur (*.key)|*.key";
var path = _shell.Environment.Dialogs.PickSaveFile("Emplacement de la nouvelle clé", filter,
Path.GetFileName(KeyPath), directory is not null && Directory.Exists(directory) ? directory : null);
if (path is null) return;
if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) path += extension;
KeyPath = path;
}
}
@@ -0,0 +1,442 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>
/// Écran de connexion : choix de la clé, phrase de passe ou code PIN (transmis par la vue, jamais
/// stocké dans le ViewModel), connexion par défi signé. Pour les clés de la puce TPM : suppression
/// de la clé, et aide quand la puce est bloquée par sa protection anti-essais (temps d'attente).
/// </summary>
public sealed class LoginViewModel : ObservableObject, IDisposable
{
private static readonly TimeSpan LockoutTick = TimeSpan.FromSeconds(1);
private readonly AdminShellViewModel _shell;
private AdminKeyEntry? _selectedKey;
private bool _isBusy;
private string? _errorMessage;
private string? _statusMessage;
// Protection anti-essais de la puce (commune à toutes les clés TPM de ce PC).
private bool _isTpmLocked;
private DateTimeOffset? _lockoutUntil;
private TimeSpan _lockoutInterval;
private bool _lockoutHeals = true;
private string? _lockoutText;
private ITimer? _lockoutTimer;
private bool _disposed;
public LoginViewModel(AdminShellViewModel shell, string? statusMessage, string? selectKeyPath)
{
_shell = shell;
_statusMessage = statusMessage;
BrowseKeyCommand = new RelayCommand(BrowseKey, () => !IsBusy);
ShowEnrollCommand = new RelayCommand(shell.ShowEnroll, () => !IsBusy);
RefreshCommand = new RelayCommand(() => LoadKeys(SelectedKey?.Path), () => !IsBusy);
OpenKeysFolderCommand = new RelayCommand(OpenKeysFolder);
DeleteTpmKeyCommand = new AsyncRelayCommand(DeleteTpmKeyAsync, () => IsTpmKey && !IsBusy);
RecheckLockoutCommand = new AsyncRelayCommand(() => CheckLockoutAsync(knownLocked: false), () => !IsBusy);
LoadKeys(selectKeyPath ?? shell.Settings.LastKeyPath);
}
public ObservableCollection<AdminKeyEntry> Keys { get; } = new();
public AdminKeyEntry? SelectedKey
{
get => _selectedKey;
set
{
if (!SetProperty(ref _selectedKey, value)) return;
OnPropertyChanged(nameof(CanLogin));
OnPropertyChanged(nameof(SecretLabel));
OnPropertyChanged(nameof(IsTpmKey));
OnPropertyChanged(nameof(ShowTpmLockout));
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
// Clé de la puce : la puce est-elle bloquée ? (affiché avant même de saisir le code)
if (value?.IsTpm == true && !_isTpmLocked) _ = CheckLockoutAsync(knownLocked: false, quiet: true);
}
}
public bool HasKeys => Keys.Count > 0;
/// <summary>« Phrase de passe de la clé » ou « Code PIN de la clé (puce TPM) », selon la clé choisie.</summary>
public string SecretLabel => SelectedKey?.SecretLabel ?? "Phrase de passe de la clé";
public bool IsTpmKey => SelectedKey?.IsTpm == true;
public bool IsBusy
{
get => _isBusy;
private set
{
if (!SetProperty(ref _isBusy, value)) return;
OnPropertyChanged(nameof(CanLogin));
BrowseKeyCommand.RaiseCanExecuteChanged();
ShowEnrollCommand.RaiseCanExecuteChanged();
RefreshCommand.RaiseCanExecuteChanged();
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
RecheckLockoutCommand.RaiseCanExecuteChanged();
}
}
/// <summary>
/// Connexion possible. Avec une clé de la puce pendant un blocage dont la fin est connue : non,
/// jusqu'à la fin du décompte (un essai serait refusé de toute façon).
/// </summary>
public bool CanLogin => SelectedKey is not null && !IsBusy && !(IsTpmKey && LockoutBlocksLogin);
public string? ErrorMessage
{
get => _errorMessage;
private set
{
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
/// <summary>Information (ex. « Verrouillé après 10 minutes d'inactivité », clé supprimée…).</summary>
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public string ServerHost => _shell.ServerHost;
public string KeysDirectory => _shell.Environment.KeysDirectory;
// ------------------------------------------------------------------ Blocage de la puce
/// <summary>La puce refuse les codes PIN (trop d'erreurs) : bandeau d'aide affiché pour les clés TPM.</summary>
public bool IsTpmLocked
{
get => _isTpmLocked;
private set
{
if (!SetProperty(ref _isTpmLocked, value)) return;
OnPropertyChanged(nameof(ShowTpmLockout));
OnPropertyChanged(nameof(CanLogin));
}
}
public bool ShowTpmLockout => IsTpmLocked && IsTpmKey;
/// <summary>« De nouveau utilisable dans 9 min 30 s au plus… », ou l'explication si la durée est inconnue.</summary>
public string? LockoutText
{
get => _lockoutText;
private set => SetProperty(ref _lockoutText, value);
}
private bool LockoutBlocksLogin => _isTpmLocked && _lockoutUntil is { } until && until > _shell.Environment.Time.GetUtcNow();
public RelayCommand BrowseKeyCommand { get; }
public RelayCommand ShowEnrollCommand { get; }
public RelayCommand RefreshCommand { get; }
public RelayCommand OpenKeysFolderCommand { get; }
/// <summary>Supprime définitivement la clé de la puce sélectionnée (clés TPM uniquement).</summary>
public AsyncRelayCommand DeleteTpmKeyCommand { get; }
/// <summary>Relit l'état de la protection anti-essais de la puce.</summary>
public AsyncRelayCommand RecheckLockoutCommand { get; }
/// <summary>Dernière lecture de l'état de la puce (tests).</summary>
internal Task LockoutCheck { get; private set; } = Task.CompletedTask;
// ------------------------------------------------------------------ Connexion
/// <summary>Connexion avec la clé choisie. La vue efface la phrase de passe (ou le code) juste après l'appel.</summary>
public async Task LoginAsync(string passphrase)
{
if (SelectedKey is not { } key || IsBusy) return;
if (string.IsNullOrEmpty(passphrase))
{
ErrorMessage = key.IsTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.";
return;
}
if (!CanLogin) return;
IsBusy = true;
ErrorMessage = null;
AdminConnection? connection = null;
TpmPinException? pinError = null;
try
{
var environment = _shell.Environment;
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm).LoginAsync(key.Path, passphrase);
}
catch (Exception ex)
{
ErrorMessage = ApiErrorText.Describe(ex, duringLogin: true);
if (ex is ApiException api) Log.Warn($"Administration : connexion refusée ({api.Status} {api.Code}).");
pinError = ex as TpmPinException;
}
finally
{
IsBusy = false;
}
if (connection is null)
{
if (pinError is not null) await OnTpmPinRefusedAsync(pinError);
return;
}
Log.Info($"Administration : connexion de {connection.Session.Profile.DisplayName} ({connection.Session.Profile.Role}).");
_shell.Settings.Remember(key.Path); // chemin déjà normalisé (AdminKeyLocator)
_shell.Settings.Save(_shell.Environment.SettingsPath); // erreurs d'écriture journalisées, sans effet
try
{
await _shell.OpenWorkspaceAsync(connection);
}
catch (Exception ex)
{
Log.Error("Administration : ouverture de l'espace d'administration", ex);
ErrorMessage = ApiErrorText.Describe(ex);
}
}
/// <summary>Code refusé : essais restants, ou décompte jusqu'à la fin du blocage.</summary>
private async Task OnTpmPinRefusedAsync(TpmPinException error)
{
var info = await CheckLockoutAsync(error.IsLockedOut);
if (_isTpmLocked)
{
ErrorMessage = null; // le bandeau du blocage explique tout
return;
}
if (info is { MaxTries: > 0 })
ErrorMessage = $"Code PIN incorrect. Encore {info.RemainingTries} essai(s) avant le blocage temporaire de la puce " +
"(compteur commun à la puce : les codes faux de Windows Hello comptent aussi).";
}
// ------------------------------------------------------------------ Protection anti-essais
/// <summary>
/// Lit l'état de la puce et met à jour le bandeau : bloquée avec une fin estimée (décompte),
/// bloquée sans estimation possible, ou débloquée.
/// </summary>
/// <param name="knownLocked">La puce vient de répondre « bloquée » (même si l'état n'est pas lisible).</param>
/// <param name="quiet">Vérification de fond : rien n'est affiché si la puce n'est pas bloquée.</param>
private Task<TpmLockoutInfo?> CheckLockoutAsync(bool knownLocked, bool quiet = false)
{
// Une lecture à la fois, dans l'ordre des demandes (une lecture de fond ne masque pas un blocage signalé).
var task = CheckAfterAsync(LockoutCheck, knownLocked, quiet);
LockoutCheck = task;
return task;
}
private async Task<TpmLockoutInfo?> CheckAfterAsync(Task previous, bool knownLocked, bool quiet)
{
try
{
await previous;
}
catch (Exception)
{
// erreur déjà journalisée par la lecture précédente
}
return await CheckCoreAsync(knownLocked, quiet);
}
private async Task<TpmLockoutInfo?> CheckCoreAsync(bool knownLocked, bool quiet)
{
if (_shell.Environment.Tpm is not { } tpm || _disposed) return null;
TpmLockoutInfo? info = null;
try
{
info = await BackgroundWork.RunAsync(tpm.GetLockoutInfo);
}
catch (Exception ex)
{
Log.Warn($"Administration : état de la protection anti-essais de la puce illisible ({ex.Message}).");
}
if (_disposed) return info;
var wasLocked = _isTpmLocked;
var locked = info?.IsLockedOut ?? knownLocked;
if (!locked)
{
StopLockout();
if (wasLocked && !quiet) StatusMessage = "La puce de sécurité accepte de nouveau les codes PIN : vous pouvez réessayer.";
return info;
}
var time = _shell.Environment.Time;
_lockoutHeals = info?.HealsWithTime ?? true;
_lockoutInterval = info?.RecoveryInterval ?? TimeSpan.Zero;
_lockoutUntil = info is { HealsWithTime: true } ? time.GetUtcNow() + info.MaxWait : null;
IsTpmLocked = true;
if (_lockoutUntil is not null && _lockoutTimer is null && _shell.Environment.StartTimers)
_lockoutTimer = time.CreateTimer(_ => _shell.Environment.Ui.Post(() => _ = TickLockoutAsync()), null, LockoutTick, LockoutTick);
UpdateLockoutText();
Log.Info(info is null
? "Administration : puce TPM bloquée (protection anti-essais), durée inconnue."
: $"Administration : puce TPM bloquée ({info.Failures}/{info.MaxTries} erreurs, une erreur oubliée toutes les {info.RecoveryInterval}).");
return info;
}
/// <summary>Décompte (chaque seconde ; appelé directement par les tests).</summary>
internal async Task TickLockoutAsync()
{
if (!_isTpmLocked || _disposed || !LockoutCheck.IsCompleted) return;
UpdateLockoutText();
OnPropertyChanged(nameof(CanLogin));
// Fin estimée atteinte : la puce le confirme (sinon, nouvelle estimation).
if (_lockoutUntil is { } until && _shell.Environment.Time.GetUtcNow() >= until) await CheckLockoutAsync(knownLocked: false);
}
private void UpdateLockoutText()
{
if (!_lockoutHeals)
{
LockoutText = "La puce de sécurité est bloquée après trop de codes PIN faux, et ne se débloque pas d'elle-même sur ce PC : " +
"un administrateur de ce PC doit réinitialiser sa protection anti-essais. En attendant, utilisez une clé « fichier ».";
return;
}
if (_lockoutUntil is not { } until)
{
LockoutText = "La puce de sécurité est temporairement bloquée après trop de codes PIN faux. Elle se débloque d'elle-même " +
"au bout d'un moment (de quelques minutes à quelques heures selon le PC), uniquement PC allumé. " +
"Réessayez plus tard, ou utilisez une clé « fichier ».";
return;
}
var remaining = until - _shell.Environment.Time.GetUtcNow();
LockoutText = remaining > TimeSpan.Zero
? $"La puce de sécurité est bloquée après trop de codes PIN faux. Elle sera de nouveau utilisable dans " +
$"{FormatWait(remaining)} au plus (une erreur est oubliée toutes les {FormatWait(_lockoutInterval)}, PC allumé)."
: "Vérification de la puce…";
}
private void StopLockout()
{
_lockoutTimer?.Dispose();
_lockoutTimer = null;
_lockoutUntil = null;
LockoutText = null;
IsTpmLocked = false;
}
/// <summary>« 9 min 05 s », « 45 s », « 1 h 30 min ».</summary>
internal static string FormatWait(TimeSpan wait)
{
if (wait < TimeSpan.Zero) wait = TimeSpan.Zero;
var seconds = (long)Math.Ceiling(wait.TotalSeconds);
if (seconds < 60) return $"{seconds} s";
if (seconds < 3600) return seconds % 60 == 0 ? $"{seconds / 60} min" : $"{seconds / 60} min {seconds % 60:00} s";
var minutes = (seconds + 59) / 60;
return minutes % 60 == 0 ? $"{minutes / 60} h" : $"{minutes / 60} h {minutes % 60:00} min";
}
// ------------------------------------------------------------------ Suppression d'une clé TPM
private async Task DeleteTpmKeyAsync()
{
if (SelectedKey is not { IsTpm: true } key || IsBusy) return;
if (_shell.Environment.Tpm is not { } tpm)
{
ErrorMessage = "La puce de sécurité (TPM) n'est pas accessible sur ce PC : la clé ne peut pas être supprimée ici.";
return;
}
var dialogs = _shell.Environment.Dialogs;
if (!dialogs.Confirm("Supprimer la clé de la puce ?",
$"La clé « {key.Name} » ({key.Detail}) sera effacée DÉFINITIVEMENT de la puce de sécurité de ce PC, " +
$"avec ses fichiers {Path.GetFileName(key.Path)} et {Path.GetFileNameWithoutExtension(key.Path)}.pub.\n\n" +
"Elle ne pourra plus jamais servir à se connecter, sur aucun PC. Si c'était votre seule clé, il faudra une " +
"nouvelle invitation (ou la commande add-key sur le serveur).\n\n" +
$"Pensez aussi à la révoquer sur le serveur (identifiant de clé {key.KeyId ?? "inconnu"}).",
"Supprimer définitivement", "Annuler", danger: true))
return;
IsBusy = true;
ErrorMessage = null;
try
{
var reference = AdminTpmKeyFile.Load(key.Path);
await BackgroundWork.RunAsync(() =>
{
tpm.DeleteKey(reference.KeyName);
return true;
});
var left = AdminTpmKeyFile.DeleteFiles(key.Path);
_shell.Settings.Forget(key.Path);
_shell.Settings.Save(_shell.Environment.SettingsPath);
Log.Info($"Administration : clé TPM {key.KeyId} supprimée de la puce ({key.Path}).");
StatusMessage = $"Clé « {key.Name} » supprimée de la puce de ce PC. Révoquez-la aussi sur le serveur " +
$"(identifiant de clé {key.KeyId ?? "inconnu"} : commande revoke-key sur le VPS)." +
(left.Count == 0 ? "" : $" Fichier(s) à supprimer à la main : {string.Join(", ", left)}.");
LoadKeys(null);
}
catch (Exception ex)
{
Log.Warn($"Administration : suppression de la clé TPM impossible ({ex.Message}).");
ErrorMessage = $"Suppression impossible : {ApiErrorText.Describe(ex)}";
}
finally
{
IsBusy = false;
}
}
// ------------------------------------------------------------------ Clés
private void LoadKeys(string? select)
{
Keys.Clear();
foreach (var entry in AdminKeyLocator.Find(KeysDirectory, _shell.Settings.KeyPaths)) Keys.Add(entry);
SelectedKey = Keys.FirstOrDefault(k => string.Equals(k.Path, select, StringComparison.OrdinalIgnoreCase)) ?? Keys.FirstOrDefault();
OnPropertyChanged(nameof(HasKeys));
}
private void BrowseKey()
{
var path = _shell.Environment.Dialogs.PickOpenFile("Choisir votre clé d'administrateur",
"Clé d'administrateur (*.key, *.tpmkey)|*.key;*.tpmkey", Directory.Exists(KeysDirectory) ? KeysDirectory : null);
if (path is null) return;
if (AdminKeyLocator.IsReserved(path))
{
ErrorMessage = "Ce fichier est la clé de signature du launcher : choisissez votre clé d'administrateur.";
return;
}
if (AdminKeyLocator.Describe(path) is not { } entry)
{
ErrorMessage = "Ce fichier n'est pas une clé d'administrateur (fichier .key chiffré créé par le launcher ou tools\\admin-key.bat, " +
"ou référence .tpmkey d'une clé de la puce de ce PC).";
return;
}
ErrorMessage = null;
_shell.Settings.Remember(entry.Path);
_shell.Settings.Save(_shell.Environment.SettingsPath);
LoadKeys(entry.Path);
}
private void OpenKeysFolder()
{
Directory.CreateDirectory(KeysDirectory);
_shell.Environment.Dialogs.OpenFolder(KeysDirectory);
}
/// <summary>Écran quitté : arrêt du décompte.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_lockoutTimer?.Dispose();
_lockoutTimer = null;
}
}
@@ -0,0 +1,18 @@
namespace GameLauncher.Modules.Admin;
/// <summary>Page du menu d'administration déclarée par un module.</summary>
/// <param name="Id">Identifiant unique, ex. « admin.dashboard », « catalog.games ».</param>
/// <param name="Title">Titre affiché dans la navigation.</param>
/// <param name="Description">Sous-titre affiché en haut de la page.</param>
/// <param name="IconKey">Icône du launcher (ressource « Icon.&lt;clé&gt; » de Themes/Icons.xaml).</param>
/// <param name="RequiredPermission">Permission nécessaire (null : tout administrateur connecté).</param>
/// <param name="Order">Position dans la navigation (croissante).</param>
/// <param name="CreateViewModel">Crée le ViewModel de la page (son affichage est un DataTemplate du launcher).</param>
public sealed record AdminPanelDescriptor(
string Id,
string Title,
string Description,
string IconKey,
string? RequiredPermission,
int Order,
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
@@ -0,0 +1,78 @@
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin;
/// <summary>Base des pages d'administration : état « occupé », message d'erreur, gestion de la session perdue.</summary>
public abstract class AdminPanelViewModel : ObservableObject
{
private bool _isBusy;
private string? _errorMessage;
private bool _activated;
protected AdminPanelViewModel(IAdminContext context) => Context = context;
public IAdminContext Context { get; }
public bool IsBusy
{
get => _isBusy;
protected set
{
if (SetProperty(ref _isBusy, value)) OnBusyChanged();
}
}
/// <summary>« Occupé » a changé : réévaluer les commandes de la page (RaiseCanExecuteChanged).</summary>
protected virtual void OnBusyChanged()
{
}
public string? ErrorMessage
{
get => _errorMessage;
protected set
{
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
public Task ActivateAsync()
{
var first = !_activated;
_activated = true;
return OnActivatedAsync(first);
}
protected virtual Task OnActivatedAsync(bool firstTime) => Task.CompletedTask;
/// <summary>
/// Exécute une action réseau : « occupé » pendant l'action, erreur affichée en cas d'échec,
/// retour à la connexion si la session a expiré. Vrai si l'action a réussi.
/// </summary>
protected async Task<bool> RunAsync(Func<Task> action)
{
if (IsBusy) return false;
IsBusy = true;
ErrorMessage = null;
try
{
await action();
return true;
}
catch (Exception ex)
{
if (ApiErrorText.IsSessionLost(ex))
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
else
ErrorMessage = ApiErrorText.Describe(ex);
return false;
}
finally
{
IsBusy = false;
}
}
}
@@ -0,0 +1,28 @@
using GameLauncher.ApiClient;
using GameLauncher.Core.Models;
namespace GameLauncher.Modules.Admin;
/// <summary>Ce qu'une page d'administration reçoit : session, jeux déclarés, dialogues, navigation.</summary>
public interface IAdminContext
{
/// <summary>Session ouverte : lectures avec GetAsync, modifications signées avec SendSignedAsync.</summary>
AdminSession Session { get; }
LauncherApiClient Client { get; }
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier).</summary>
IReadOnlyList<LauncherConfig> Games { get; }
IModuleDialogs Dialogs { get; }
string LauncherVersion { get; }
/// <summary>Pages visibles pour cet administrateur.</summary>
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
void Navigate(string panelId);
/// <summary>La session n'est plus valable : retour à l'écran de connexion avec ce message.</summary>
void ReportSessionLost(string reason);
}
+31
View File
@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
namespace GameLauncher.Modules;
/// <summary>Message clair (français) pour une erreur survenue dans un module d'administration.</summary>
public static class ApiErrorText
{
/// <param name="duringLogin">Vrai pendant la connexion : un refus vise la clé, pas la session.</param>
public static string Describe(Exception ex, bool duringLogin = false) => ex switch
{
ApiException { Status: 0, Code: "unreachable" } =>
"Serveur d'administration injoignable. Vérifiez votre connexion Internet, puis réessayez.",
ApiException { Status: 0, Code: "timeout" } => "Le serveur d'administration ne répond pas. Réessayez dans un instant.",
ApiException { Status: 401 } api when duringLogin && api.Code == ApiErrorCodes.Unauthorized =>
"Connexion refusée : cette clé n'est pas reconnue par le serveur, est encore en attente d'approbation, ou a été révoquée.",
ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized } => "Session expirée : reconnectez-vous.",
ApiException api => api.Message,
CryptographicException crypto => crypto.Message,
FileNotFoundException missing => $"Fichier introuvable : {missing.FileName ?? missing.Message}",
IOException or UnauthorizedAccessException => $"Fichier inaccessible : {ex.Message}",
ArgumentException or InvalidOperationException => ex.Message,
OperationCanceledException => "Opération annulée.",
_ => $"Erreur inattendue : {ex.Message}"
};
/// <summary>La session n'est plus valable (expirée, révoquée) : il faut se reconnecter.</summary>
public static bool IsSessionLost(Exception ex) =>
ex is ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized };
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Socle des modules du launcher (sans WPF) : contrat d'un module, registre, pages du menu
d'administration, boîtes de dialogue abstraites, MVVM commun. Chaque module (Administration,
puis Actualités, Amis…) est une bibliothèque qui référence ce projet : sa logique et ses
ViewModels sont testables sans interface ; le launcher (WPF) n'en fournit que l'affichage.
Voir docs\ADMIN-ARCHITECTURE.md, § 5.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Modules</RootNamespace>
<AssemblyName>GameLauncher.Modules</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Core\GameLauncher.Core.csproj" />
<ProjectReference Include="..\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,74 @@
using GameLauncher.Modules.Admin;
namespace GameLauncher.Modules;
/// <summary>
/// Module du launcher. Il déclare ce qu'il apporte au registre ; il ne connaît ni les fenêtres ni
/// les autres modules. Les modules actifs sont listés dans App (launcher WPF).
/// </summary>
public interface ILauncherModule
{
/// <summary>Identifiant stable, ex. « admin », « news ».</summary>
string Id { get; }
string DisplayName { get; }
void Register(IModuleRegistry registry);
}
/// <summary>Ce qu'un module peut ajouter au launcher.</summary>
public interface IModuleRegistry
{
/// <summary>Page du menu d'administration (affichée seulement aux administrateurs ayant la permission requise).</summary>
void AddAdminPanel(AdminPanelDescriptor panel);
}
/// <summary>Modules actifs et ce qu'ils ont déclaré.</summary>
public sealed class ModuleCatalog : IModuleRegistry
{
private readonly List<AdminPanelDescriptor> _adminPanels = new();
private ILauncherModule? _registering;
public ModuleCatalog(IEnumerable<ILauncherModule> modules)
{
var list = new List<ILauncherModule>();
foreach (var module in modules)
{
if (string.IsNullOrWhiteSpace(module.Id) || list.Any(m => string.Equals(m.Id, module.Id, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Identifiant de module invalide ou en double : « {module.Id} ».");
list.Add(module);
_registering = module;
try
{
module.Register(this);
}
finally
{
_registering = null;
}
}
Modules = list;
}
public IReadOnlyList<ILauncherModule> Modules { get; }
/// <summary>Toutes les pages d'administration, dans l'ordre d'affichage.</summary>
public IReadOnlyList<AdminPanelDescriptor> AdminPanels =>
_adminPanels.OrderBy(p => p.Order).ThenBy(p => p.Title, StringComparer.CurrentCulture).ToList();
/// <summary>Pages visibles pour un administrateur (selon ses permissions ; le serveur reste seul juge).</summary>
public IReadOnlyList<AdminPanelDescriptor> AdminPanelsFor(IReadOnlyCollection<string> permissions) =>
AdminPanels.Where(p => p.RequiredPermission is null || permissions.Contains(p.RequiredPermission)).ToList();
void IModuleRegistry.AddAdminPanel(AdminPanelDescriptor panel)
{
ArgumentNullException.ThrowIfNull(panel);
if (_registering is null) throw new InvalidOperationException("Les pages se déclarent dans ILauncherModule.Register.");
if (string.IsNullOrWhiteSpace(panel.Id) || _adminPanels.Any(p => string.Equals(p.Id, panel.Id, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException($"Page d'administration invalide ou en double : « {panel.Id} » (module {_registering.Id}).");
if (panel.RequiredPermission is { } permission &&
(permission.Length > 64 || permission.Count(c => c == '.') != 1 || permission.StartsWith('.') || permission.EndsWith('.')))
throw new InvalidOperationException($"Permission invalide pour la page « {panel.Id} » : « {permission} » (format module.action).");
_adminPanels.Add(panel);
}
}
@@ -0,0 +1,40 @@
namespace GameLauncher.Modules;
public enum ModuleMessageKind
{
Info,
Success,
Warning,
Error
}
/// <summary>Boîtes de dialogue et actions système offertes aux modules (implémentées par le launcher WPF).</summary>
public interface IModuleDialogs
{
void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info);
bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false);
/// <param name="filter">Filtre au format Windows, ex. « Clé d'administrateur (*.key)|*.key ».</param>
string? PickOpenFile(string title, string filter, string? initialDirectory);
string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory);
void CopyToClipboard(string text);
void OpenFolder(string path);
}
/// <summary>Exécution sur le fil de l'interface (minuteries, notifications venues d'un autre fil).</summary>
public interface IUiDispatcher
{
void Post(Action action);
}
/// <summary>Exécution immédiate (tests, outils en ligne de commande).</summary>
public sealed class ImmediateDispatcher : IUiDispatcher
{
public static readonly ImmediateDispatcher Instance = new();
public void Post(Action action) => action();
}
@@ -43,9 +43,12 @@ public sealed class AsyncRelayCommand : ICommand
public bool CanExecute(object? parameter) => !_isRunning && (_canExecute?.Invoke() ?? true);
public async void Execute(object? parameter)
public async void Execute(object? parameter) => await ExecuteAsync();
/// <summary>Exécute la commande et attend la fin (tests, enchaînements).</summary>
public async Task ExecuteAsync()
{
if (!CanExecute(parameter)) return;
if (!CanExecute(null)) return;
_isRunning = true;
RaiseCanExecuteChanged();
try
+2
View File
@@ -11,6 +11,8 @@
<ResourceDictionary Source="Themes/Controls.xaml" />
<!-- Notes de version (format enrichi) : modèles partagés par la fenêtre principale et la fenêtre agrandie -->
<ResourceDictionary Source="Themes/Changelog.xaml" />
<!-- Menu d'administration : styles et modèles des pages des modules (Views/Admin) -->
<ResourceDictionary Source="Themes/Admin.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
+49
View File
@@ -9,6 +9,8 @@ using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.ViewModels;
@@ -43,6 +45,7 @@ public partial class App : Application, ILauncherRestartHost
private Views.MainWindow? _mainWindow;
private HttpDownloader? _http;
private LauncherUpdateService? _updates;
private AdminWindowService? _admin;
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -323,12 +326,56 @@ public partial class App : Application, ILauncherRestartHost
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
launcher.AttachTransition(window);
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A ou 5 clics sur le logo). Pas en mode --capture.
if (!readOnly)
{
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
_admin = new AdminWindowService(window, CreateModules(), catalog, apiUrl, apiUrlError, version);
window.AdminRequested += _admin.Open;
}
window.DataContext = launcher;
window.Closed += (_, _) => Shutdown();
window.Show();
return window;
}
/// <summary>Modules du launcher (pages du menu d'administration…). Les futurs modules s'ajoutent ici.</summary>
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
{
new AdminModule()
});
/// <summary>
/// Adresse de l'API d'administration (« apiUrl »). En Release, elle n'est lue QUE dans le
/// launcher.json intégré à l'exécutable : un launcher.json posé à côté de l'exe (ou --config) ne
/// peut pas rediriger les administrateurs vers un autre serveur. En Debug, la configuration
/// utilisée est suivie (serveur local http://127.0.0.1:5080/api/ pour les tests).
/// </summary>
/// <returns>L'adresse (ou null) et, si elle a été ignorée car invalide, la raison.</returns>
private static (string? ApiUrl, string? Error) ResolveAdminApiUrl(LauncherCatalog catalog)
{
#if DEBUG
if (catalog.ApiUrlError is not null) Log.Warn(catalog.ApiUrlError);
return (catalog.ApiUrl, catalog.ApiUrlError);
#else
try
{
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
if (catalog.ApiUrl is not null && !string.Equals(catalog.ApiUrl, integrated?.ApiUrl, StringComparison.Ordinal))
Log.Warn("« apiUrl » du launcher.json externe ignorée : seule celle intégrée à l'exécutable est utilisée.");
if (integrated?.ApiUrlError is { } error) Log.Warn(error);
return (integrated?.ApiUrl, integrated?.ApiUrlError);
}
catch (Exception ex)
{
Log.Error("Adresse de l'API d'administration (launcher.json intégré)", ex);
return (null, "launcher.json intégré illisible.");
}
#endif
}
/// <summary>Option --capture : une capture PNG par jeu (état réel, sans action automatique), puis fermeture.</summary>
private async Task CaptureAsync(Views.MainWindow window, string directory)
{
@@ -381,6 +428,8 @@ public partial class App : Application, ILauncherRestartHost
{
_instanceChannel?.Dispose();
_instanceChannel = null;
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
_admin = null;
_trayIcon?.Dispose(); // sinon l'icône resterait affichée jusqu'au survol
_trayIcon = null;
_launcher?.Dispose();
+3
View File
@@ -49,6 +49,9 @@
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Core\GameLauncher.Core.csproj" />
<!-- Modules (SDK commun + menu d'administration) : logique et ViewModels, sans WPF ; les vues sont ici (Views\Admin, Themes\Admin.xaml). -->
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
</ItemGroup>
</Project>
+115
View File
@@ -0,0 +1,115 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Services;
using GameLauncher.Views;
using Microsoft.Win32;
namespace GameLauncher.Platform;
/// <summary>
/// Boîtes de dialogue offertes aux modules (menu d'administration…) : mêmes fenêtres que le reste
/// du launcher (ModernDialog), au-dessus de la fenêtre du module.
/// </summary>
public sealed class ModuleDialogs : IModuleDialogs
{
private readonly Func<Window?> _owner;
/// <param name="owner">Fenêtre propriétaire du moment (null : centrée sur l'écran).</param>
public ModuleDialogs(Func<Window?> owner) => _owner = owner;
private Window? Owner => _owner() is { IsLoaded: true, IsVisible: true } window ? window : null;
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) =>
ModernDialog.ShowMessage(Owner, title, message, kind switch
{
ModuleMessageKind.Success => DialogKind.Success,
ModuleMessageKind.Warning => DialogKind.Warning,
ModuleMessageKind.Error => DialogKind.Error,
_ => DialogKind.Info
});
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false) =>
ModernDialog.ShowConfirm(Owner, title, message, confirmText, cancelText,
danger ? DialogKind.Danger : DialogKind.Question, null).Confirmed;
public string? PickOpenFile(string title, string filter, string? initialDirectory)
{
var dialog = new OpenFileDialog
{
Title = title,
Filter = filter,
Multiselect = false,
CheckFileExists = true,
DereferenceLinks = true
};
if (!string.IsNullOrWhiteSpace(initialDirectory) && Directory.Exists(initialDirectory))
dialog.InitialDirectory = initialDirectory;
var owner = Owner;
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
return result == true ? dialog.FileName : null;
}
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory)
{
var dialog = new SaveFileDialog
{
Title = title,
Filter = filter,
FileName = defaultFileName,
OverwritePrompt = false, // les services refusent d'écraser un fichier existant (ex. une clé)
AddExtension = true
};
if (!string.IsNullOrWhiteSpace(initialDirectory) && Directory.Exists(initialDirectory))
dialog.InitialDirectory = initialDirectory;
var owner = Owner;
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
return result == true ? dialog.FileName : null;
}
public void CopyToClipboard(string text)
{
// Le presse-papiers peut être momentanément occupé par une autre application.
for (var attempt = 1; ; attempt++)
{
try
{
Clipboard.SetText(text);
return;
}
catch (COMException ex) when (attempt < 3)
{
Log.Warn($"Presse-papiers occupé ({ex.ErrorCode:X8}), nouvel essai.");
Thread.Sleep(60);
}
catch (Exception ex)
{
Log.Error("Copie dans le presse-papiers impossible", ex);
ShowMessage("Copie impossible", "Le presse-papiers est utilisé par une autre application : réessayez.", ModuleMessageKind.Warning);
return;
}
}
}
/// <summary>Dossier LOCAL existant uniquement (jamais une adresse web ni un partage réseau \\serveur\…).</summary>
public void OpenFolder(string path)
{
if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\", StringComparison.Ordinal) ||
path.StartsWith("//", StringComparison.Ordinal) || !Directory.Exists(path))
{
Log.Warn($"Ouverture de dossier refusée (chemin local attendu) : {path}");
return;
}
try
{
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = true });
}
catch (Exception ex)
{
Log.Error($"Ouverture du dossier impossible : {path}", ex);
}
}
}
+454
View File
@@ -0,0 +1,454 @@
using System.Buffers.Binary;
using System.Runtime.InteropServices;
using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Services;
namespace GameLauncher.Platform;
/// <summary>
/// Clés d'administrateur dans la puce de sécurité (TPM 2.0) de ce PC, via le fournisseur Windows
/// « Microsoft Platform Crypto Provider » (CNG) :
/// • la clé ECDSA P-256 est GÉNÉRÉE par la puce ; ce fournisseur ne permet jamais d'exporter la clé
/// privée (Windows n'en garde qu'une copie chiffrée par la puce elle-même, inutilisable ailleurs) ;
/// • clé de l'utilisateur Windows courant (un autre compte du PC ne peut pas l'ouvrir) ;
/// • chaque usage exige le code PIN (« usage authorization » de la clé), vérifié par la puce, qui se
/// bloque temporairement après plusieurs codes faux (protection anti-essais du TPM) ;
/// • aucune fenêtre Windows : le code est saisi dans le launcher puis effacé ; la clé est ouverte
/// en mode silencieux.
/// </summary>
public sealed class TpmKeyStore : IAdminTpm
{
/// <summary>Autorisation d'usage de la clé (NCRYPT_PCP_USAGEAUTH_PROPERTY) : SHA-1 du code PIN en UTF-16.</summary>
private const string UsageAuthProperty = "PCP_USAGEAUTH";
private static CngProvider Provider => CngProvider.MicrosoftPlatformCryptoProvider;
/// <summary>Préfixe des clés de test de <see cref="Check"/> : distinct de celui des vraies clés.</summary>
private const string CheckKeyPrefix = "GameLauncher.AdminCheck.";
public TpmAvailability Check()
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10))
return new TpmAvailability(false, "Clé dans la puce de sécurité : Windows 10 ou plus récent est nécessaire.");
// Vérification RÉELLE : création d'une clé de test dans la puce, signature, contrôle, suppression.
var keyName = CheckKeyPrefix + Guid.NewGuid().ToString("N");
var created = false;
try
{
using var key = CreateCore(keyName, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)));
created = true;
using var signer = new TpmSigner(key);
var message = RandomNumberGenerator.GetBytes(32);
var signature = signer.SignNow(message);
if (!AdminKeys.Verify(signer.PublicKey, message, signature))
return new TpmAvailability(false, "La puce de sécurité (TPM) de ce PC a produit une signature invalide.");
return new TpmAvailability(true, "Puce de sécurité (TPM 2.0) détectée et vérifiée sur ce PC.");
}
catch (CryptographicException ex)
{
Log.Info($"Puce TPM indisponible : {ex.Message} (code 0x{CodeOf(ex):X8})");
return new TpmAvailability(false, DescribeUnavailable(ex));
}
catch (Exception ex) when (ex is PlatformNotSupportedException or NotSupportedException or ArgumentException)
{
Log.Info($"Puce TPM indisponible : {ex.GetType().Name} {ex.Message}");
return new TpmAvailability(false, "Aucune puce de sécurité (TPM 2.0) utilisable sur ce PC.");
}
finally
{
if (created) TryDelete(keyName);
}
}
public IDisposableAdminSigner CreateKey(string keyName, string pin)
{
EnsureKeyName(keyName);
CngKey key;
try
{
key = CreateCore(keyName, pin);
}
catch (CryptographicException ex)
{
throw Wrap(DescribeUnavailable(ex), ex);
}
try
{
return new TpmSigner(key);
}
catch (Exception ex)
{
key.Dispose();
TryDelete(keyName); // jamais de clé orpheline dans la puce
if (ex is CryptographicException crypto) throw Describe(crypto);
throw;
}
}
public IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin)
{
EnsureKeyName(keyName);
CngKey key;
try
{
key = CngKey.Open(keyName, Provider, CngKeyOpenOptions.Silent);
}
catch (CryptographicException ex)
{
throw Describe(ex);
}
TpmSigner? signer = null;
try
{
// Clé publique comparée AVANT tout essai du code : une référence trompeuse ne consomme
// aucun essai de la protection anti-essais de la puce.
if (!TpmSigner.ReadPublicKey(key).AsSpan().SequenceEqual(expectedPublicKey))
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
SetUsageAuth(key, pin);
signer = new TpmSigner(key);
// Le code PIN n'est vérifié par la puce qu'à l'usage : signature de test AVANT de contacter le serveur.
signer.SignNow(RandomNumberGenerator.GetBytes(32));
return signer;
}
catch (Exception ex)
{
if (signer is not null) signer.Dispose();
else key.Dispose();
if (ex is CryptographicException crypto) throw Describe(crypto);
throw;
}
}
public void DeleteKey(string keyName)
{
EnsureKeyName(keyName);
DeleteCore(keyName);
}
/// <summary>Crée la clé (code PIN fixé avant la finalisation) et fournit le code pour la poignée ouverte.</summary>
private static CngKey CreateCore(string keyName, string pin)
{
var parameters = new CngKeyCreationParameters
{
Provider = Provider,
KeyCreationOptions = CngKeyCreationOptions.None // clé de l'utilisateur Windows (pas de la machine)
};
var auth = UsageAuth(pin);
try
{
parameters.Parameters.Add(new CngProperty(UsageAuthProperty, auth, CngPropertyOptions.None));
}
finally
{
CryptographicOperations.ZeroMemory(auth);
}
var key = CngKey.Create(CngAlgorithm.ECDsaP256, keyName, parameters);
try
{
SetUsageAuth(key, pin); // une fois finalisée, la clé exige son code pour cette poignée aussi
return key;
}
catch
{
key.Dispose();
TryDelete(keyName);
throw;
}
}
private static void SetUsageAuth(CngKey key, string pin)
{
var auth = UsageAuth(pin);
try
{
key.SetProperty(new CngProperty(UsageAuthProperty, auth, CngPropertyOptions.None));
}
finally
{
CryptographicOperations.ZeroMemory(auth);
}
}
private static void DeleteCore(string keyName)
{
if (!CngKey.Exists(keyName, Provider, CngKeyOpenOptions.Silent)) return;
using var key = CngKey.Open(keyName, Provider, CngKeyOpenOptions.Silent);
key.Delete(); // supprime et libère
}
private static void TryDelete(string keyName)
{
try
{
DeleteCore(keyName);
}
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
{
Log.Warn($"Clé {keyName} non supprimée de la puce ({ex.Message}).");
}
}
private static void EnsureKeyName(string keyName)
{
// Seules les clés créées par le launcher peuvent être ouvertes ou supprimées.
if (!AdminTpmKeyFile.IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
}
/// <summary>SHA-1 du code en UTF-16 (sans zéro final) : format attendu par le fournisseur pour l'autorisation d'usage.</summary>
private static byte[] UsageAuth(string pin)
{
var bytes = Encoding.Unicode.GetBytes(pin);
try
{
return SHA1.HashData(bytes);
}
finally
{
CryptographicOperations.ZeroMemory(bytes);
}
}
// ------------------------------------------------------------------ Protection anti-essais
/// <summary>
/// Lit l'état de la protection anti-essais par la commande TPM2_GetCapability (lecture seule,
/// sans autorisation), envoyée par le service TPM de Windows (TBS). Null si la lecture est
/// impossible (pas de puce, commande réservée aux administrateurs sur ce PC…).
/// </summary>
public TpmLockoutInfo? GetLockoutInfo()
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10)) return null;
var context = IntPtr.Zero;
try
{
var parameters = new TbsContextParams2 { Version = 2, Flags = 1u << 2 }; // TBS_CONTEXT_VERSION_TWO, includeTpm20
if (Tbsi_Context_Create(ref parameters, out context) != 0) return null;
// TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES, TPM_PT_PERMANENT…TPM_PT_LOCKOUT_RECOVERY)
const uint FirstProperty = 0x200, PropertyCount = 0x12;
var command = new byte[22];
BinaryPrimitives.WriteUInt16BigEndian(command.AsSpan(0), 0x8001); // TPM_ST_NO_SESSIONS
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(2), 22); // taille
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(6), 0x0000017A); // TPM_CC_GetCapability
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(10), 6); // TPM_CAP_TPM_PROPERTIES
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(14), FirstProperty);
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(18), PropertyCount);
var response = new byte[512];
var length = (uint)response.Length;
if (Tbsip_Submit_Command(context, 0, 200, command, (uint)command.Length, response, ref length) != 0) return null;
var data = response.AsSpan(0, (int)Math.Min(length, (uint)response.Length));
if (data.Length < 19 || BinaryPrimitives.ReadUInt32BigEndian(data[6..]) != 0) return null; // code retour TPM
if (BinaryPrimitives.ReadUInt32BigEndian(data[11..]) != 6) return null;
var count = BinaryPrimitives.ReadUInt32BigEndian(data[15..]);
uint? permanent = null, counter = null, maxTries = null, interval = null;
for (var i = 0; i < count && 19 + (i + 1) * 8 <= data.Length; i++)
{
var entry = data.Slice(19 + i * 8, 8);
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
{
case 0x200: permanent = value; break; // TPM_PT_PERMANENT
case 0x20E: counter = value; break; // TPM_PT_LOCKOUT_COUNTER
case 0x20F: maxTries = value; break; // TPM_PT_MAX_AUTH_FAIL
case 0x210: interval = value; break; // TPM_PT_LOCKOUT_INTERVAL (secondes)
}
}
if (counter is null || maxTries is null || interval is null) return null;
var inLockout = permanent is { } attributes && (attributes & (1u << 2)) != 0; // TPMA_PERMANENT.inLockout
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
TimeSpan.FromSeconds(interval.Value), inLockout);
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentOutOfRangeException)
{
return null;
}
finally
{
if (context != IntPtr.Zero) Tbsip_Context_Close(context);
}
}
[StructLayout(LayoutKind.Sequential)]
private struct TbsContextParams2
{
public uint Version;
public uint Flags;
}
[DllImport("tbs.dll", ExactSpelling = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static extern uint Tbsi_Context_Create(ref TbsContextParams2 parameters, out IntPtr context);
[DllImport("tbs.dll", ExactSpelling = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static extern uint Tbsip_Submit_Command(IntPtr context, uint locality, uint priority, byte[] command, uint commandLength,
byte[] result, ref uint resultLength);
[DllImport("tbs.dll", ExactSpelling = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static extern uint Tbsip_Context_Close(IntPtr context);
// ------------------------------------------------------------------ Messages
private static class Codes
{
// Code PIN refusé par la puce (codes TPM normalisés, voir Normalize) ou par le fournisseur.
public const uint TpmAuthFail = 0x8028008E, TpmBadAuth = 0x802800A2, Tpm12AuthFail = 0x80280001, Tpm12Auth2Fail = 0x8028001D,
PcpAuthenticationFailed = 0x80290408;
// Protection anti-essais active.
public const uint TpmLockout = 0x80280921, TpmDefendLockRunning = 0x80280803;
// Clé absente, ou plus chargeable (puce réinitialisée, profil Windows itinérant…).
public const uint BadKeyset = 0x80090016, NoKey = 0x8009000D, NotFound = 0x80090011,
PcpWrongParent = 0x8029040E, PcpKeyNotLoaded = 0x8029040F;
// Puce absente, désactivée ou pas prête.
public const uint TbsTpmNotFound = 0x8028400F, TbsServiceNotRunning = 0x80284008, TbsServiceDisabled = 0x80284010,
PcpDeviceNotReady = 0x80290401, DeviceNotReady = 0x80090030, DeviceNotFound = 0x80090035,
ProviderNotFound = 0x8009001F, ProviderDllNotFound = 0x8009001D;
// Algorithme non géré (TPM 1.2 : pas de courbes elliptiques).
public const uint NotSupported = 0x80090029, BadAlgId = 0x80090008, TpmCurve = 0x802800A6;
}
/// <summary>
/// Code Windows d'origine (les exceptions décrites ci-dessous enveloppent l'erreur réelle).
/// Codes TPM 2.0 « format 1 » (0x8028_0xxx avec le bit 0x080) : les bits qui désignent la session
/// ou le paramètre en cause sont retirés (ex. 0x8028098E → 0x8028008E, code PIN refusé).
/// </summary>
private static uint CodeOf(Exception ex)
{
for (Exception? current = ex; current is not null; current = current.InnerException)
{
if (current is not CryptographicException || (uint)current.HResult >> 16 == 0x8013) continue; // code .NET générique
var hr = (uint)current.HResult;
if ((hr & 0xFFFFF000) == 0x80280000 && (hr & 0x80) != 0) hr = 0x80280000 | 0x80 | (hr & 0x3F);
return hr;
}
return (uint)ex.HResult;
}
/// <summary>Message français, en conservant l'erreur d'origine (et son code) comme cause.</summary>
private static CryptographicException Wrap(string message, CryptographicException ex) => new(message, ex);
private static CryptographicException Describe(CryptographicException ex) => CodeOf(ex) switch
{
Codes.TpmAuthFail or Codes.TpmBadAuth or Codes.Tpm12AuthFail or Codes.Tpm12Auth2Fail or Codes.PcpAuthenticationFailed =>
new TpmPinException("Code PIN incorrect. Après plusieurs erreurs, la puce se bloque temporairement (protection anti-essais).",
isLockedOut: false, ex),
Codes.TpmLockout or Codes.TpmDefendLockRunning =>
new TpmPinException("Trop de codes PIN faux : la puce de sécurité est temporairement bloquée.", isLockedOut: true, ex),
Codes.BadKeyset or Codes.NoKey or Codes.NotFound or Codes.PcpWrongParent or Codes.PcpKeyNotLoaded =>
Wrap("Clé introuvable ou inutilisable dans la puce de ce PC (autre PC, autre compte Windows, puce réinitialisée ou " +
"Windows réinstallé ?). Utilisez une autre clé, ou demandez une nouvelle invitation.", ex),
_ when ex.InnerException is null && (uint)ex.HResult >> 16 == 0x8013 => ex, // message déjà rédigé ici (ex. clé différente)
_ => Wrap(DescribeUnavailable(ex), ex)
};
private static string DescribeUnavailable(CryptographicException ex) => CodeOf(ex) switch
{
Codes.TbsTpmNotFound or Codes.DeviceNotFound or Codes.ProviderNotFound or Codes.ProviderDllNotFound =>
"Aucune puce de sécurité (TPM) détectée sur ce PC.",
Codes.TbsServiceNotRunning or Codes.TbsServiceDisabled or Codes.PcpDeviceNotReady or Codes.DeviceNotReady =>
"La puce de sécurité (TPM) de ce PC n'est pas prête (désactivée dans le BIOS/UEFI ou service arrêté).",
Codes.NotSupported or Codes.BadAlgId or Codes.TpmCurve =>
"La puce de sécurité de ce PC ne gère pas les clés ECDSA P-256 (TPM 2.0 nécessaire).",
Codes.TpmLockout or Codes.TpmDefendLockRunning =>
"La puce de sécurité est temporairement bloquée (protection anti-essais). Réessayez plus tard.",
var code => $"Puce de sécurité (TPM) inutilisable : {(ex.InnerException ?? ex).Message} (code 0x{code:X8})."
};
// ------------------------------------------------------------------ Signataire
/// <summary>Clé ouverte dans la puce : elle signe (dans la puce), la poignée est fermée au verrouillage.</summary>
private sealed class TpmSigner : IDisposableAdminSigner
{
private readonly ECDsaCng _ecdsa;
private readonly SemaphoreSlim _gate = new(1, 1);
private int _disposed;
public TpmSigner(CngKey key)
{
PublicKey = ReadPublicKey(key);
KeyId = AdminKeys.ComputeKeyId(PublicKey);
_ecdsa = new ECDsaCng(key);
key.Dispose(); // ECDsaCng garde sa propre référence sur la même poignée (code PIN compris)
}
public byte[] PublicKey { get; }
public string KeyId { get; }
/// <summary>Signature immédiate (appelant déjà hors du fil de l'interface).</summary>
public byte[] SignNow(byte[] message)
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
return _ecdsa.SignData(message, HashAlgorithmName.SHA256); // IEEE P1363, 64 octets
}
/// <summary>
/// Signature dans la puce (lente) : hors du fil de l'interface, une à la fois. L'erreur éventuelle
/// est relancée ici, pas dans Task.Run (pas d'arrêt du débogueur sur une erreur gérée).
/// </summary>
public async ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ExceptionDispatchInfo? failure = null;
var signature = await Task.Run(() =>
{
try
{
return SignNow(message);
}
catch (Exception ex)
{
failure = ExceptionDispatchInfo.Capture(ex is CryptographicException crypto ? Describe(crypto) : ex);
return null;
}
}, ct).ConfigureAwait(false);
failure?.Throw();
return signature!;
}
finally
{
_gate.Release();
}
}
/// <summary>
/// Clé publique lue au format brut de Windows (BCRYPT_ECCKEY_BLOB : « ECS1 », 32, X, Y), puis
/// encodée en SPKI : ne dépend pas des propriétés facultatives du fournisseur de la puce.
/// </summary>
internal static byte[] ReadPublicKey(CngKey key)
{
const uint EcdsaP256PublicMagic = 0x31534345; // « ECS1 »
const uint EcdsaGenericPublicMagic = 0x50444345; // « ECDP »
var blob = key.Export(CngKeyBlobFormat.EccPublicBlob);
var magic = blob.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(blob) : 0;
if (blob.Length != 8 + 64 || magic is not (EcdsaP256PublicMagic or EcdsaGenericPublicMagic) ||
BinaryPrimitives.ReadUInt32LittleEndian(blob.AsSpan(4)) != 32)
throw new CryptographicException("La puce n'a pas renvoyé une clé publique ECDSA P-256.");
using var ec = ECDsa.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = blob[8..40], Y = blob[40..72] }
});
return AdminKeys.ParsePublicKey(Convert.ToBase64String(ec.ExportSubjectPublicKeyInfo()));
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_ecdsa.Dispose();
}
}
}
@@ -0,0 +1,14 @@
using System.Windows.Threading;
using GameLauncher.Modules;
namespace GameLauncher.Platform;
/// <summary>Exécution sur le fil de l'interface WPF.</summary>
public sealed class WpfUiDispatcher : IUiDispatcher
{
private readonly Dispatcher _dispatcher;
public WpfUiDispatcher(Dispatcher dispatcher) => _dispatcher = dispatcher;
public void Post(Action action) => _dispatcher.InvokeAsync(action);
}
@@ -0,0 +1,106 @@
using System.IO;
using System.Windows;
using GameLauncher.ApiClient;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Platform;
using GameLauncher.Views;
using GameLauncher.Views.Admin;
namespace GameLauncher.Services;
/// <summary>
/// Ouverture du menu d'administration (accès discret depuis la fenêtre principale).
/// Une seule fenêtre à la fois ; la fermer déconnecte la session et efface la clé de la mémoire.
/// Sans adresse d'API dans launcher.json (« apiUrl »), le menu n'existe pas.
/// </summary>
public sealed class AdminWindowService : IDisposable
{
private readonly Window _owner;
private readonly ModuleCatalog _modules;
private readonly IReadOnlyList<LauncherConfig> _games;
private readonly string _settingsPath;
private readonly string _launcherVersion;
private readonly Uri? _apiUrl;
private readonly string? _apiUrlError;
private LauncherApiClient? _client;
private AdminWindow? _window;
/// <param name="apiUrl">Adresse de l'API (null : menu indisponible).</param>
/// <param name="apiUrlError">Si l'adresse configurée a été ignorée (invalide), la raison, affichée à l'ouverture.</param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, string? apiUrl, string? apiUrlError,
string launcherVersion)
{
_owner = owner;
_modules = modules;
_games = catalog.Games;
_settingsPath = Path.Combine(catalog.DataDirectory, "admin.json");
_launcherVersion = launcherVersion;
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
_apiUrlError = apiUrlError;
}
public bool IsAvailable => _apiUrl is not null;
/// <summary>Ouvre la fenêtre (ou la ramène au premier plan si elle est déjà ouverte).</summary>
public void Open()
{
if (_window is not null)
{
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
_window.Activate();
return;
}
if (_apiUrl is null)
{
Log.Info("Administration demandée, mais aucune adresse d'API (apiUrl) dans launcher.json.");
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Administration indisponible",
_apiUrlError ?? "Ce launcher n'est relié à aucun serveur d'administration (« apiUrl » absent de launcher.json).",
_apiUrlError is null ? DialogKind.Info : DialogKind.Warning);
return;
}
try
{
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
AdminWindow? window = null;
var shell = new AdminShellViewModel(new AdminEnvironment
{
Client = _client,
Modules = _modules,
Games = _games,
Dialogs = new ModuleDialogs(() => window),
Ui = new WpfUiDispatcher(_owner.Dispatcher),
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
SettingsPath = _settingsPath,
LauncherVersion = _launcherVersion
});
window = new AdminWindow(shell);
if (_owner.IsVisible) window.Owner = _owner;
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
window.Closed += (_, _) => _window = null;
_window = window;
Log.Info("Ouverture du menu d'administration.");
window.Show();
window.Activate();
}
catch (Exception ex)
{
_window = null;
Log.Error("Ouverture du menu d'administration", ex);
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Administration indisponible", ex.Message, DialogKind.Error);
}
}
/// <summary>Fermeture du launcher : la fenêtre se ferme (déconnexion, clé effacée).</summary>
public void Close() => _window?.Close();
/// <remarks>
/// Le client HTTP n'est pas libéré ici : la déconnexion du serveur, lancée par la fermeture de la
/// fenêtre, a ainsi une chance de partir avant la fin du processus (la clé, elle, est déjà effacée).
/// </remarks>
public void Dispose() => Close();
}
+431
View File
@@ -0,0 +1,431 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls"
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin">
<!--
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
Chaque page d'un module (AdminPanelViewModel) s'affiche avec le DataTemplate de son type,
déclaré ici (ou dans un dictionnaire du module, fusionné dans App.xaml).
Hors des modèles (ControlTemplate/DataTemplate), seules les ressources de CE fichier sont
utilisées en StaticResource (BasedOn…) : celles des autres dictionnaires ne sont visibles
qu'une fois l'application chargée.
-->
<!-- ============================================================ Cartes et textes -->
<Style x:Key="AdminCardStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Background" Value="{DynamicResource InputBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="20,18" />
<Setter Property="Margin" Value="0,0,0,16" />
</Style>
<Style x:Key="AdminCardTitleStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style x:Key="AdminHintStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</Style>
<Style x:Key="AdminLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Margin" Value="0,0,16,8" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</Style>
<Style x:Key="AdminValueStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style x:Key="AdminFieldLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</Style>
<!-- Empreinte d'une clé : chiffres lisibles à voix haute (« AB12 CD34 … ») -->
<Style x:Key="FingerprintTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New" />
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<!-- Pastille d'icône carrée (en-têtes de cartes) -->
<Style x:Key="AdminIconBadgeStyle" TargetType="Border">
<Setter Property="Width" Value="38" />
<Setter Property="Height" Value="38" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Background" Value="{DynamicResource BadgeBrush}" />
</Style>
<Style x:Key="AdminIconStyle" TargetType="Path">
<Setter Property="Width" Value="18" />
<Setter Property="Height" Value="18" />
<Setter Property="Stretch" Value="Uniform" />
<Setter Property="Fill" Value="{DynamicResource Accent2Brush}" />
</Style>
<!-- Avatar rond avec les initiales -->
<Style x:Key="AdminAvatarStyle" TargetType="Border">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="Background" Value="{DynamicResource AccentGradientBrush}" />
</Style>
<Style x:Key="AdminAvatarTextStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
</Style>
<!-- Étiquette (rôle, permission) -->
<Style x:Key="AdminChipStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="10,4" />
<Setter Property="Margin" Value="0,0,8,8" />
<Setter Property="Background" Value="{DynamicResource ChipBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- Bandeaux d'erreur / d'information -->
<Style x:Key="AdminErrorBannerStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="Margin" Value="0,0,0,14" />
<Setter Property="Background" Value="#26E5484D" />
<Setter Property="BorderBrush" Value="{DynamicResource DangerBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style x:Key="AdminInfoBannerStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="Margin" Value="0,0,0,14" />
<Setter Property="Background" Value="{DynamicResource ChipBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- ============================================================ Navigation (pages des modules) -->
<Style x:Key="AdminNavItemStyle" TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,4" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="ToolTip" Value="{Binding Description}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Grid Background="Transparent">
<Border x:Name="Bd" CornerRadius="12" Background="Transparent" Padding="12,10">
<StackPanel Orientation="Horizontal">
<Path x:Name="Icon" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Center"
Fill="{DynamicResource TextSecondaryBrush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
<TextBlock x:Name="Label" Text="{Binding Title}" Margin="12,0,0,0" VerticalAlignment="Center"
FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
</Border>
<Border x:Name="Indicator" Width="4" Height="20" CornerRadius="2" HorizontalAlignment="Left"
Margin="-2,0,0,0" Background="{DynamicResource AccentGradientBrush}" Opacity="0" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
<Setter TargetName="Label" Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Label" Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter TargetName="Indicator" Property="Opacity" Value="1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ============================================================ Choix de la clé (écran de connexion) -->
<Style x:Key="AdminKeyItemStyle" TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="ToolTip" Value="{Binding Path}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" CornerRadius="14" Padding="14,10" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="34" Height="34" CornerRadius="11" Background="{DynamicResource BadgeBrush}">
<Path x:Name="KeyIcon" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.KeyVariant}" />
</Border>
<StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
<DockPanel>
<!-- Clé conservée dans la puce de sécurité de ce PC -->
<Border x:Name="TpmBadge" DockPanel.Dock="Right" CornerRadius="6" Padding="6,1" Margin="8,0,0,0"
VerticalAlignment="Center" Background="{DynamicResource ChipBrush}"
BorderBrush="{DynamicResource Accent2Brush}" BorderThickness="1" Visibility="Collapsed">
<TextBlock Text="TPM" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource Accent2Brush}" />
</Border>
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
</DockPanel>
<TextBlock Text="{Binding Detail}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<Path x:Name="Check" Grid.Column="2" Width="18" Height="18" Stretch="Uniform" Margin="10,0,0,0"
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.CheckCircle}" Visibility="Collapsed" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
<Setter TargetName="Check" Property="Visibility" Value="Visible" />
</Trigger>
<DataTrigger Binding="{Binding IsTpm}" Value="True">
<Setter TargetName="KeyIcon" Property="Data" Value="{DynamicResource Icon.Chip}" />
<Setter TargetName="TpmBadge" Property="Visibility" Value="Visible" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ============================================================ Choix du rangement de la clé (« J'ai une invitation ») -->
<!-- RadioButton en carte : Content = titre, Tag = description, ui:IconAssist.Icon = icône. -->
<Style x:Key="AdminChoiceCardStyle" TargetType="RadioButton">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="Bd" CornerRadius="14" Padding="14,12" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="34" Height="34" CornerRadius="11" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
<Path Width="17" Height="17" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding Path=(ui:IconAssist.Icon), RelativeSource={RelativeSource TemplatedParent}}" />
</Border>
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center">
<ContentPresenter TextElement.FontSize="13" TextElement.FontWeight="SemiBold" />
<TextBlock Text="{TemplateBinding Tag}" FontSize="11.5" Margin="0,3,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<Grid Grid.Column="2" Width="18" Height="18" VerticalAlignment="Center">
<Ellipse x:Name="Ring" Stroke="{DynamicResource BorderStrongBrush}" StrokeThickness="1.5" />
<Ellipse x:Name="Dot" Margin="4" Fill="{DynamicResource Accent2Brush}" Visibility="Collapsed" />
</Grid>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
<Setter TargetName="Ring" Property="Stroke" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Dot" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.45" />
<Setter Property="Cursor" Value="Arrow" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ============================================================ Page « Tableau de bord » -->
<DataTemplate DataType="{x:Type avm:DashboardPanelViewModel}">
<StackPanel>
<!-- Erreur de chargement -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Compte connecté -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminAvatarStyle}" Width="52" Height="52" CornerRadius="26" VerticalAlignment="Top">
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" FontSize="17" />
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding DisplayName}" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<Border Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" Padding="9,2" VerticalAlignment="Center">
<TextBlock Text="{Binding RoleText}" FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource Accent2Brush}" />
</Border>
</StackPanel>
<TextBlock Text="{Binding KeyText}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0" />
<TextBlock Text="{Binding SessionText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,12" />
<ItemsControl ItemsSource="{Binding Permissions}" Focusable="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource AdminChipStyle}">
<StackPanel Orientation="Horizontal">
<Path Width="11" Height="11" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}"
Data="{StaticResource Icon.Check}" VerticalAlignment="Center" />
<TextBlock Text="{Binding}" FontSize="11.5" Margin="6,0,0,0" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Grid>
</Border>
<!-- Clés en attente d'approbation (gestion des administrateurs) -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding CanManageAdmins, Converter={StaticResource BoolToVisibility}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path x:Name="PendingIcon" Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.AccountKey}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0" VerticalAlignment="Center">
<TextBlock Text="Administrateurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock x:Name="PendingText" Text="{Binding PendingText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</Grid>
</Border>
<!-- Serveur et launcher -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.Server}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Serveur" Style="{StaticResource AdminCardTitleStyle}" Margin="0,0,0,12" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Adresse" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ServerOrigin}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="1" Text="Version du serveur" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ServerVersion}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="2" Text="Identifiant" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding ServerId}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="3" Text="Version du launcher" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LauncherVersion}" Style="{StaticResource AdminValueStyle}" />
</Grid>
</StackPanel>
</Grid>
</Border>
<!-- Jeux déclarés dans launcher.json -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.Gamepad}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Jeux de ce launcher" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Déclarés dans launcher.json (solution Visual Studio)." Style="{StaticResource AdminHintStyle}" Margin="0,3,0,10" />
<ItemsControl ItemsSource="{Binding Games}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock FontSize="12.5" TextTrimming="CharacterEllipsis">
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text="{Binding GameId, Mode=OneWay}" Foreground="{DynamicResource TextMutedBrush}" />
</TextBlock>
<TextBlock Grid.Column="1" Text="{Binding ManifestHost}" FontSize="12" Margin="12,0,0,0"
Foreground="{DynamicResource TextMutedBrush}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Grid>
</Border>
<!-- Actualisation -->
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Text="{Binding LastRefresh}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding HasPendingApprovals}" Value="True">
<Setter TargetName="PendingIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="PendingText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ResourceDictionary>
+36
View File
@@ -459,6 +459,42 @@
</Setter>
</Style>
<!-- Champ de phrase de passe : même apparence que ModernTextBoxStyle (administration) -->
<Style x:Key="ModernPasswordBoxStyle" TargetType="PasswordBox">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="Background" Value="{DynamicResource InputBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
<Setter Property="CaretBrush" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="SelectionBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Padding" Value="12,0" />
<Setter Property="Height" Value="40" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="PasswordBox">
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" Focusable="False"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ============================================================ Menu contextuel arrondi -->
<Style TargetType="ContextMenu">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
+27
View File
@@ -44,4 +44,31 @@
<Geometry x:Key="Icon.ArrowRight">M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z</Geometry>
<Geometry x:Key="Icon.LinkOff">M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.43 19.12,14.63 17.79,15L19.25,16.44C20.88,15.61 22,13.95 22,12A5,5 0 0,0 17,7M16,11H13.81L15.81,13H16V11M2,4.27L5.11,7.38C3.29,8.12 2,9.91 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12C3.9,10.41 5.11,9.1 6.66,8.93L8.73,11H8V13H10.73L13,15.27V17H14.73L18.74,21L20,19.74L3.27,3L2,4.27Z</Geometry>
<!-- Administration (module Admin) -->
<Geometry x:Key="Icon.ShieldAccount">M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M17.13,17C15.92,18.85 14.11,20.24 12,20.92C9.89,20.24 8.08,18.85 6.87,17C6.53,16.5 6.24,16 6,15.47C6,13.82 8.71,12.47 12,12.47C15.29,12.47 18,13.79 18,15.47C17.76,16 17.47,16.5 17.13,17Z</Geometry>
<Geometry x:Key="Icon.Lock">M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z</Geometry>
<Geometry x:Key="Icon.LockOpenVariant">M18 1C15.24 1 13 3.24 13 6V8H4C2.9 8 2 8.89 2 10V20C2 21.11 2.9 22 4 22H16C17.11 22 18 21.11 18 20V10C18 8.9 17.11 8 16 8H15V6C15 4.34 16.34 3 18 3C19.66 3 21 4.34 21 6V8H23V6C23 3.24 20.76 1 18 1M10 13C11.1 13 12 13.89 12 15C12 16.11 11.11 17 10 17C8.9 17 8 16.11 8 15C8 13.9 8.9 13 10 13Z</Geometry>
<Geometry x:Key="Icon.KeyVariant">M22,18V22H18V19H15V16H12L9.74,13.74C9.19,13.91 8.61,14 8,14A6,6 0 0,1 2,8A6,6 0 0,1 8,2A6,6 0 0,1 14,8C14,8.61 13.91,9.19 13.74,9.74L22,18M7,5A2,2 0 0,0 5,7A2,2 0 0,0 7,9A2,2 0 0,0 9,7A2,2 0 0,0 7,5Z</Geometry>
<Geometry x:Key="Icon.KeyPlus">M7.5 3C9.5 3 11.1 4.2 11.7 6H21V9H18V12H15V9H11.7C11.1 10.8 9.4 12 7.5 12C5 12 3 10 3 7.5S5 3 7.5 3M7.5 6C6.7 6 6 6.7 6 7.5S6.7 9 7.5 9 9 8.3 9 7.5 8.3 6 7.5 6M8 17H11V14H13V17H16V19H13V22H11V19H8V17Z</Geometry>
<Geometry x:Key="Icon.FileKey">M11 16C11 16.6 10.6 17 10 17S9 16.6 9 16C9 15.4 9.4 15 10 15S11 15.4 11 16M20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14M18 15H12.8C12.2 13.4 10.5 12.6 9 13.2C7.4 13.8 6.6 15.5 7.2 17S9.5 19.4 11 18.8C11.9 18.5 12.5 17.8 12.8 17H14V19H16V17H18M18.5 9L13 3.5V9H18.5Z</Geometry>
<Geometry x:Key="Icon.AccountCircle">M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z</Geometry>
<Geometry x:Key="Icon.Login">M11 7L9.6 8.4L12.2 11H2V13H12.2L9.6 15.6L11 17L16 12L11 7M20 19H12V21H20C21.1 21 22 20.1 22 19V5C22 3.9 21.1 3 20 3H12V5H20V19Z</Geometry>
<Geometry x:Key="Icon.Logout">M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z</Geometry>
<Geometry x:Key="Icon.ViewDashboard">M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z</Geometry>
<Geometry x:Key="Icon.Server">M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z</Geometry>
<Geometry x:Key="Icon.ContentCopy">M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z</Geometry>
<Geometry x:Key="Icon.Fingerprint">M17.81,4.47C17.73,4.47 17.65,4.45 17.58,4.41C15.66,3.42 14,3 12,3C10.03,3 8.15,3.47 6.44,4.41C6.2,4.54 5.9,4.45 5.76,4.21C5.63,3.97 5.72,3.66 5.96,3.53C7.82,2.5 9.86,2 12,2C14.14,2 16,2.47 18.04,3.5C18.29,3.65 18.38,3.95 18.25,4.19C18.16,4.37 18,4.47 17.81,4.47M3.5,9.72C3.4,9.72 3.3,9.69 3.21,9.63C3,9.47 2.93,9.16 3.09,8.93C4.08,7.53 5.34,6.43 6.84,5.66C10,4.04 14,4.03 17.15,5.65C18.65,6.42 19.91,7.5 20.9,8.9C21.06,9.12 21,9.44 20.78,9.6C20.55,9.76 20.24,9.71 20.08,9.5C19.18,8.22 18.04,7.23 16.69,6.54C13.82,5.07 10.15,5.07 7.29,6.55C5.93,7.25 4.79,8.25 3.89,9.5C3.81,9.65 3.66,9.72 3.5,9.72M9.75,21.79C9.62,21.79 9.5,21.74 9.4,21.64C8.53,20.77 8.06,20.21 7.39,19C6.7,17.77 6.34,16.27 6.34,14.66C6.34,11.69 8.88,9.27 12,9.27C15.12,9.27 17.66,11.69 17.66,14.66A0.5,0.5 0 0,1 17.16,15.16A0.5,0.5 0 0,1 16.66,14.66C16.66,12.24 14.57,10.27 12,10.27C9.43,10.27 7.34,12.24 7.34,14.66C7.34,16.1 7.66,17.43 8.27,18.5C8.91,19.66 9.35,20.15 10.12,20.93C10.31,21.13 10.31,21.44 10.12,21.64C10,21.74 9.88,21.79 9.75,21.79M16.92,19.94C15.73,19.94 14.68,19.64 13.82,19.05C12.33,18.04 11.44,16.4 11.44,14.66A0.5,0.5 0 0,1 11.94,14.16A0.5,0.5 0 0,1 12.44,14.66C12.44,16.07 13.16,17.4 14.38,18.22C15.09,18.7 15.92,18.93 16.92,18.93C17.16,18.93 17.56,18.9 17.96,18.83C18.23,18.78 18.5,18.96 18.54,19.24C18.59,19.5 18.41,19.77 18.13,19.82C17.56,19.93 17.06,19.94 16.92,19.94M14.91,22C14.87,22 14.82,22 14.78,22C13.19,21.54 12.15,20.95 11.06,19.88C9.66,18.5 8.89,16.64 8.89,14.66C8.89,13.04 10.27,11.72 11.97,11.72C13.67,11.72 15.05,13.04 15.05,14.66C15.05,15.73 16,16.6 17.13,16.6C18.28,16.6 19.21,15.73 19.21,14.66C19.21,10.89 15.96,7.83 11.96,7.83C9.12,7.83 6.5,9.41 5.35,11.86C4.96,12.67 4.76,13.62 4.76,14.66C4.76,15.44 4.83,16.67 5.43,18.27C5.53,18.53 5.4,18.82 5.14,18.91C4.88,19 4.59,18.87 4.5,18.62C4,17.31 3.77,16 3.77,14.66C3.77,13.46 4,12.37 4.45,11.42C5.78,8.63 8.73,6.82 11.96,6.82C16.5,6.82 20.21,10.33 20.21,14.65C20.21,16.27 18.83,17.59 17.13,17.59C15.43,17.59 14.05,16.27 14.05,14.65C14.05,13.58 13.12,12.71 11.97,12.71C10.82,12.71 9.89,13.58 9.89,14.65C9.89,16.36 10.55,17.96 11.76,19.16C12.71,20.1 13.62,20.62 15.03,21C15.3,21.08 15.45,21.36 15.38,21.62C15.33,21.85 15.12,22 14.91,22Z</Geometry>
<Geometry x:Key="Icon.TicketAccount">M20,12A2,2 0 0,0 22,14V18A2,2 0 0,1 20,20H4A2,2 0 0,1 2,18V14C3.11,14 4,13.1 4,12A2,2 0 0,0 2,10V6C2,4.89 2.9,4 4,4H20A2,2 0 0,1 22,6V10A2,2 0 0,0 20,12M16.5,16.25C16.5,14.75 13.5,14 12,14C10.5,14 7.5,14.75 7.5,16.25V17H16.5V16.25M12,12.25A2.25,2.25 0 0,0 14.25,10A2.25,2.25 0 0,0 12,7.75A2.25,2.25 0 0,0 9.75,10A2.25,2.25 0 0,0 12,12.25Z</Geometry>
<Geometry x:Key="Icon.ClockOutline">M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z</Geometry>
<Geometry x:Key="Icon.FolderKey">M4 4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V8C22 6.9 21.1 6 20 6H12L10 4M11 10C12.3 10 13.4 10.8 13.8 12H20V14H18V16H16V14H13.8C13.4 15.2 12.3 16 11 16C9.3 16 8 14.7 8 13S9.3 10 11 10M11 12C10.4 12 10 12.4 10 13C10 13.6 10.4 14 11 14C11.6 14 12 13.6 12 13S11.6 12 11 12Z</Geometry>
<Geometry x:Key="Icon.ArrowLeft">M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z</Geometry>
<Geometry x:Key="Icon.ChevronRight">M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z</Geometry>
<Geometry x:Key="Icon.AccountGroup">M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z</Geometry>
<Geometry x:Key="Icon.AccountKey">M11 10V12H9V14H7V12H5.8C5.4 13.2 4.3 14 3 14C1.3 14 0 12.7 0 11S1.3 8 3 8C4.3 8 5.4 8.8 5.8 10H11M3 10C2.4 10 2 10.4 2 11S2.4 12 3 12 4 11.6 4 11 3.6 10 3 10M16 14C18.7 14 24 15.3 24 18V20H8V18C8 15.3 13.3 14 16 14M16 12C13.8 12 12 10.2 12 8S13.8 4 16 4 20 5.8 20 8 18.2 12 16 12Z</Geometry>
<Geometry x:Key="Icon.ShieldKey">M12,8A1,1 0 0,1 13,9A1,1 0 0,1 12,10A1,1 0 0,1 11,9A1,1 0 0,1 12,8M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,6A3,3 0 0,0 9,9C9,10.31 9.83,11.42 11,11.83V18H13V16H15V14H13V11.83C14.17,11.42 15,10.31 15,9A3,3 0 0,0 12,6Z</Geometry>
<Geometry x:Key="Icon.EmailOutline">M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z</Geometry>
<Geometry x:Key="Icon.Chip">M6,4H18V5H21V7H18V9H21V11H18V13H21V15H18V17H21V19H18V20H6V19H3V17H6V15H3V13H6V11H3V9H6V7H3V5H6V4M11,15V18H12V15H11M13,15V18H14V15H13M15,15V18H16V15H15Z</Geometry>
<Geometry x:Key="Icon.FileLockOutline">M18 13C16.6 13 15.2 14.1 15.2 15.5V17C14.6 17 14 17.6 14 18.2V21.7C14 22.4 14.6 23 15.2 23H20.7C21.4 23 22 22.4 22 21.8V18.3C22 17.6 21.4 17 20.8 17V15.5C20.8 14.1 19.4 13 18 13M18 14.2C18.8 14.2 19.5 14.7 19.5 15.5V17H16.5V15.5C16.5 14.7 17.2 14.2 18 14.2M6 2C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H12V20H6V4H13V9H18V11C18.7 11 19.4 11.2 20 11.4V8L14 2H6Z</Geometry>
<Geometry x:Key="Icon.BadgeAccountHorizontal">M22,4H14V7H10V4H2A2,2 0 0,0 0,6V20A2,2 0 0,0 2,22H22A2,2 0 0,0 24,20V6A2,2 0 0,0 22,4M8,9A2,2 0 0,1 10,11A2,2 0 0,1 8,13A2,2 0 0,1 6,11A2,2 0 0,1 8,9M12,17H4V16C4,14.67 6.67,14 8,14C9.33,14 12,14.67 12,16V17M20,18H14V16H20V18M20,14H14V12H20V14M20,10H14V8H20V10M13,6H11V2H13V6Z</Geometry>
</ResourceDictionary>
@@ -0,0 +1,164 @@
<Window x:Class="GameLauncher.Views.Admin.AdminWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls"
xmlns:local="clr-namespace:GameLauncher.Views.Admin"
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin"
Title="{Binding WindowTitle}"
Width="1080"
Height="720"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ResizeMode="CanMinimize"
WindowStartupLocation="CenterOwner"
Icon="/Assets/launcher.ico"
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal">
<!--
Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo).
Un seul écran à la fois, selon AdminShellViewModel.Screen :
• LoginViewModel → choix de la clé + phrase de passe ;
• EnrollViewModel → « J'ai une invitation » (création de clé, empreinte à communiquer) ;
• AdminWorkspaceViewModel → navigation entre les pages des modules + page affichée.
-->
<Window.Resources>
<DataTemplate DataType="{x:Type avm:LoginViewModel}">
<local:LoginView />
</DataTemplate>
<DataTemplate DataType="{x:Type avm:EnrollViewModel}">
<local:EnrollView />
</DataTemplate>
<!-- ================================================= Espace d'administration (connecté) -->
<DataTemplate DataType="{x:Type avm:AdminWorkspaceViewModel}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Navigation -->
<Border Background="{DynamicResource RailBrush}" CornerRadius="0,0,0,20"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0">
<DockPanel Margin="16,20,16,18">
<!-- Compte connecté -->
<Grid DockPanel.Dock="Top" Margin="4,0,0,22">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminAvatarStyle}">
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" />
</Border>
<StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}" FontSize="13.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding RoleText}" FontSize="11.5" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</Grid>
<!-- Session : verrouillage -->
<StackPanel DockPanel.Dock="Bottom">
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,0,0,14" />
<StackPanel Orientation="Horizontal" Margin="4,0,0,4">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource TextMutedBrush}"
Data="{StaticResource Icon.ClockOutline}" VerticalAlignment="Center" />
<TextBlock Text="{Binding SessionText}" FontSize="11.5" Margin="8,0,0,0"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="4,0,0,14">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource TextMutedBrush}"
Data="{StaticResource Icon.KeyVariant}" VerticalAlignment="Center" />
<TextBlock Text="{Binding KeyLabel}" FontSize="11.5" Margin="8,0,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<Button Style="{StaticResource SecondaryButtonStyle}" HorizontalAlignment="Stretch" Content="Verrouiller"
ui:IconAssist.Icon="{StaticResource Icon.Lock}" Command="{Binding LockCommand}"
ToolTip="Ferme la session et efface la clé de la mémoire (Ctrl+L)" />
</StackPanel>
<TextBlock DockPanel.Dock="Top" Text="ADMINISTRATION" Style="{StaticResource SectionTitleStyle}" Margin="6,0,0,10" />
<ListBox ItemsSource="{Binding PanelItems}" SelectedItem="{Binding SelectedPanel, Mode=TwoWay}"
ItemContainerStyle="{StaticResource AdminNavItemStyle}"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Border>
<!-- Page affichée -->
<DockPanel Grid.Column="1" Margin="32,22,18,18">
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,18">
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" />
<TextBlock Text="{Binding PanelDescription}" FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0">
<!-- Modèle choisi selon le type de la page (Themes/Admin.xaml) -->
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False" />
</ScrollViewer>
</DockPanel>
</Grid>
</DataTemplate>
</Window.Resources>
<Grid Margin="20">
<!-- Ombre portée -->
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Border.Effect>
</Border>
<Border CornerRadius="20" Background="{DynamicResource SurfaceSolidBrush}"
BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="64" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Barre de titre -->
<Grid Background="Transparent" Margin="22,0,14,0" MouseLeftButtonDown="OnTitleBarMouseDown">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="36" Height="36" CornerRadius="11" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{StaticResource Icon.ShieldAccount}" Fill="{DynamicResource OnAccentBrush}"
Width="19" Height="19" Stretch="Uniform" />
</Border>
<StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock Text="Administration" FontSize="15" FontWeight="SemiBold" />
<TextBlock FontSize="11" Foreground="{DynamicResource TextMutedBrush}">
<Run Text="Serveur" />
<Run Text="{Binding ServerHost, Mode=OneWay}" />
</TextBlock>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Border CornerRadius="10" Padding="10,5" Margin="0,0,12,0" Background="{DynamicResource ChipBrush}"
Visibility="{Binding IsLoggedIn, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="8" Height="8" Fill="{DynamicResource SuccessBrush}" VerticalAlignment="Center" />
<TextBlock Text="Connecté" FontSize="11.5" Margin="7,0,0,0" />
</StackPanel>
</Border>
<Button Style="{StaticResource IconButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
ToolTip="Réduire" Click="OnMinimize" />
<Button Style="{StaticResource CloseButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Close}"
ToolTip="Fermer (déconnexion)" Margin="4,0,0,0" Click="OnClose" />
</StackPanel>
</Grid>
<Border Grid.Row="0" Height="1" VerticalAlignment="Bottom" Background="{DynamicResource BorderBrush}" />
<!-- Écran courant -->
<ContentControl Grid.Row="1" Content="{Binding Screen}" Focusable="False" />
</Grid>
</Border>
</Grid>
</Window>
@@ -0,0 +1,120 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.ViewModels;
using Microsoft.Win32;
namespace GameLauncher.Views.Admin;
/// <summary>
/// Fenêtre du menu d'administration. Toute la logique est dans <see cref="AdminShellViewModel"/> ;
/// la fenêtre signale l'activité (report du verrouillage automatique), verrouille quand la session
/// Windows est verrouillée, et déconnecte à la fermeture (la clé déchiffrée est effacée de la mémoire).
/// </summary>
public partial class AdminWindow : Window
{
private readonly AdminShellViewModel _shell;
private bool _closed;
private Point? _lastMousePosition;
private bool _windowsSessionLocked;
public AdminWindow(AdminShellViewModel shell)
{
InitializeComponent();
_shell = shell;
DataContext = shell;
PreviewMouseMove += OnPreviewMouseMove;
PreviewMouseDown += (_, _) => _shell.NotifyActivity();
PreviewMouseWheel += (_, _) => _shell.NotifyActivity();
PreviewKeyDown += OnPreviewKeyDown;
Loaded += (_, _) => FitToScreen();
// Session Windows verrouillée (Win+L, mise en veille) : l'administration se verrouille aussi.
SystemEvents.SessionSwitch += OnSessionSwitch;
// Connexion terminée pendant que Windows était verrouillé : verrouillage immédiat.
_shell.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(AdminShellViewModel.IsLoggedIn) && _shell.IsLoggedIn && _windowsSessionLocked)
_ = _shell.LockAsync("Verrouillé avec la session Windows.");
};
}
/// <summary>
/// Seul un VRAI déplacement compte comme activité : WPF envoie aussi des MouseMove « synthétiques »
/// quand la mise en page change sous un curseur immobile (texte du temps restant, animations),
/// qui empêcheraient sinon le verrouillage automatique.
/// </summary>
private void OnPreviewMouseMove(object sender, MouseEventArgs e)
{
var position = PointToScreen(e.GetPosition(this));
if (_lastMousePosition is { } last && Math.Abs(last.X - position.X) < 1 && Math.Abs(last.Y - position.Y) < 1) return;
_lastMousePosition = position;
_shell.NotifyActivity();
}
/// <summary>Jamais plus haute que la zone utile de l'écran (petits écrans, mise à l'échelle 125-150 %).</summary>
private void FitToScreen()
{
var work = SystemParameters.WorkArea;
if (Height > work.Height) Height = work.Height;
if (Width > work.Width) Width = work.Width;
if (Top < work.Top) Top = work.Top;
if (Left < work.Left) Left = work.Left;
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
_shell.NotifyActivity();
if (e.Key == Key.L && Keyboard.Modifiers == ModifierKeys.Control && _shell.IsLoggedIn)
{
e.Handled = true;
_ = _shell.LockAsync("Session verrouillée.");
}
}
private void OnSessionSwitch(object sender, SessionSwitchEventArgs e)
{
var locked = e.Reason is SessionSwitchReason.SessionLock or SessionSwitchReason.ConsoleDisconnect or SessionSwitchReason.RemoteDisconnect;
var unlocked = e.Reason is SessionSwitchReason.SessionUnlock or SessionSwitchReason.ConsoleConnect or SessionSwitchReason.RemoteConnect;
if (!locked && !unlocked) return;
// Événement reçu sur un autre thread.
Dispatcher.InvokeAsync(() =>
{
_windowsSessionLocked = locked;
if (locked && !_closed && _shell.IsLoggedIn) _ = _shell.LockAsync("Verrouillé avec la session Windows.");
});
}
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
DragMove();
}
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void OnClose(object sender, RoutedEventArgs e) => Close();
protected override void OnClosing(CancelEventArgs e)
{
base.OnClosing(e);
if (e.Cancel) return;
_closed = true;
SystemEvents.SessionSwitch -= OnSessionSwitch; // événement statique : sinon la fenêtre ne serait jamais libérée
}
protected override async void OnClosed(EventArgs e)
{
base.OnClosed(e);
try
{
await _shell.DisposeAsync(); // clé effacée immédiatement, puis déconnexion du serveur
}
catch (Exception ex)
{
Log.Error("Administration : fermeture", ex);
}
}
}
@@ -0,0 +1,212 @@
<UserControl x:Class="GameLauncher.Views.Admin.EnrollView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls">
<!--
« J'ai une invitation » : la clé est créée sur ce PC (fichier chiffré par la phrase de passe),
seule la clé PUBLIQUE est envoyée au serveur avec le code d'invitation. L'empreinte affichée
ensuite est à communiquer à la personne qui approuve, par un autre canal (téléphone…).
-->
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False">
<Grid Width="520" Margin="0,24,0,30" HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- ================================================= Formulaire -->
<StackPanel Visibility="{Binding IsForm, Converter={StaticResource BoolToVisibility}}">
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" FontSize="12.5" Margin="0,0,0,18"
Content="Retour à la connexion" ui:IconAssist.Icon="{StaticResource Icon.ArrowLeft}"
Command="{Binding BackCommand}" />
<Grid Margin="0,0,0,22">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Width="52" Height="52" CornerRadius="16" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{StaticResource Icon.KeyPlus}" Fill="{DynamicResource OnAccentBrush}" Width="24" Height="24" Stretch="Uniform" />
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock Text="Créer ma clé d'administrateur" FontSize="20" FontWeight="SemiBold" />
<TextBlock Text="La clé privée reste sur ce PC (fichier chiffré ou puce de sécurité). Seule la clé publique est envoyée."
FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
</Grid>
<TextBlock Text="Code d'invitation" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Margin="0,0,0,4" CharacterCasing="Upper" MaxLength="64"
FontFamily="Cascadia Mono, Consolas, Courier New" FontSize="14"
Text="{Binding InvitationCode, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,0,0,16"
Text="Reçu de l'administrateur qui vous invite (utilisable une seule fois, valable 24 h par défaut). Laissez vide pour créer seulement la clé (premier propriétaire, déclaré sur le serveur)." />
<TextBlock Text="Nom de ce PC (visible par les propriétaires)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Margin="0,0,0,16" MaxLength="64"
Text="{Binding KeyLabel, UpdateSourceTrigger=PropertyChanged}" />
<!-- Rangement de la clé : la puce n'est proposée qu'une fois RÉELLEMENT vérifiée -->
<TextBlock Text="Où ranger ma clé ?" Style="{StaticResource AdminFieldLabelStyle}" />
<RadioButton Style="{StaticResource AdminChoiceCardStyle}" GroupName="StorageFile" Margin="0,0,0,8"
IsChecked="{Binding UseFile, Mode=TwoWay}" IsEnabled="{Binding CanChooseFile}"
ui:IconAssist.Icon="{StaticResource Icon.FileLockOutline}" Content="Fichier chiffré"
Tag="Protégé par une phrase de passe. Copiable et sauvegardable (clé USB…) : utilisable sur plusieurs PC." />
<RadioButton Style="{StaticResource AdminChoiceCardStyle}" GroupName="StorageTpm" Margin="0,0,0,6"
IsChecked="{Binding UseTpm, Mode=TwoWay}" IsEnabled="{Binding CanChooseTpm}"
ui:IconAssist.Icon="{StaticResource Icon.Chip}" Content="Puce de sécurité (TPM) de ce PC"
Tag="Créée dans la puce, protégée par un code PIN : elle ne peut pas être copiée, même par un virus. Liée à ce PC et à votre compte Windows." />
<StackPanel Orientation="Horizontal" Margin="4,0,0,18">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="16" Height="3" IsIndeterminate="True" Margin="0,0,8,0"
VerticalAlignment="Center" Visibility="{Binding IsCheckingTpm, Converter={StaticResource BoolToVisibility}}" />
<Path Width="13" Height="13" Stretch="Uniform" Margin="0,1,8,0" VerticalAlignment="Top"
Visibility="{Binding IsCheckingTpm, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Data" Value="{DynamicResource Icon.Info}" />
<Setter Property="Fill" Value="{DynamicResource TextMutedBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsTpmAvailable}" Value="True">
<Setter Property="Data" Value="{DynamicResource Icon.CheckCircle}" />
<Setter Property="Fill" Value="{DynamicResource SuccessBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
<TextBlock Text="{Binding TpmStatus}" Style="{StaticResource AdminHintStyle}" MaxWidth="480" />
</StackPanel>
<TextBlock Text="{Binding KeyFileLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
<Border Style="{StaticResource AdminCardStyle}" Padding="14,8" Margin="0,0,0,16">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Changer…"
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding ChangePathCommand}" />
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.FileKey}" VerticalAlignment="Center" />
<TextBlock Text="{Binding KeyPath}" Margin="10,0,12,0" VerticalAlignment="Center" FontSize="12.5"
TextTrimming="CharacterEllipsis" ToolTip="{Binding KeyPath}" />
</DockPanel>
</Border>
<TextBlock Text="{Binding SecretLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
<PasswordBox x:Name="PassphraseBox" Style="{StaticResource ModernPasswordBoxStyle}" Margin="0,0,0,16" />
<TextBlock Text="{Binding ConfirmationLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
<PasswordBox x:Name="ConfirmationBox" Style="{StaticResource ModernPasswordBoxStyle}" Margin="0,0,0,4"
KeyDown="OnConfirmationKeyDown" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,0,0,18" Text="{Binding SecretHint}" />
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="450" />
</StackPanel>
</Border>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" Margin="0,0,0,12" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Height="44" HorizontalAlignment="Stretch" Content="Créer ma clé"
ui:IconAssist.Icon="{StaticResource Icon.KeyVariant}" Click="OnSubmitClick" />
</StackPanel>
<!-- ================================================= Résultat -->
<StackPanel Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}}">
<Border Width="64" Height="64" CornerRadius="20" HorizontalAlignment="Center" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{StaticResource Icon.ShieldKey}" Fill="{DynamicResource OnAccentBrush}" Width="28" Height="28" Stretch="Uniform" />
</Border>
<!-- Avec invitation : en attente d'approbation -->
<StackPanel Visibility="{Binding IsEnrolled, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Clé enregistrée, en attente d'approbation" FontSize="20" FontWeight="SemiBold" Margin="0,18,0,0"
HorizontalAlignment="Center" />
<TextBlock Margin="0,6,0,22" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="12.5" Foreground="{DynamicResource TextSecondaryBrush}">
<Run Text="Compte :" />
<Run Text="{Binding AccountName, Mode=OneWay}" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimaryBrush}" />
<LineBreak />
<Run Text="Communiquez l'empreinte ci-dessous à la personne qui approuve, de vive voix ou par un autre canal que celui de l'invitation. Elle la saisira pour approuver votre clé." />
</TextBlock>
</StackPanel>
<!-- Sans invitation : clé seule -->
<StackPanel Visibility="{Binding IsKeyOnly, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Clé créée sur ce PC" FontSize="20" FontWeight="SemiBold" Margin="0,18,0,0" HorizontalAlignment="Center" />
<TextBlock Margin="0,6,0,22" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="12.5" Foreground="{DynamicResource TextSecondaryBrush}"
Text="Elle n'est encore associée à aucun compte. Premier propriétaire : déclarez la clé publique (.pub) sur le serveur avec la commande add-owner (docs\ADMIN-ARCHITECTURE.md). Clé perdue ou nouveau PC sans invitation : add-key. Sinon, demandez une invitation." />
</StackPanel>
<!-- Invitation envoyée, réponse du serveur perdue : clé conservée -->
<StackPanel Visibility="{Binding IsUnconfirmed, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Enregistrement non confirmé" FontSize="20" FontWeight="SemiBold" Margin="0,18,0,14" HorizontalAlignment="Center" />
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding UnconfirmedText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="450" />
</StackPanel>
</Border>
</StackPanel>
<!-- Empreinte -->
<Border Style="{StaticResource AdminCardStyle}" Padding="20,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Path Width="26" Height="26" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Fingerprint}"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="16,0,12,0" VerticalAlignment="Center">
<TextBlock Text="EMPREINTE DE LA CLÉ" Style="{StaticResource SectionTitleStyle}" Margin="0,0,0,4" />
<TextBlock Text="{Binding Fingerprint}" Style="{StaticResource FingerprintTextStyle}" />
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource SmallButtonStyle}" Content="Copier" VerticalAlignment="Center"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyFingerprintCommand}" />
</Grid>
</Border>
<!-- Fichiers -->
<Border Style="{StaticResource AdminCardStyle}" Padding="20,14">
<StackPanel>
<!-- Clé dans la puce -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,10"
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}}">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.Chip}"
VerticalAlignment="Center" />
<TextBlock Text="Clé rangée dans la puce de sécurité (TPM) de ce PC : elle ne peut pas être copiée."
Margin="8,0,0,0" FontSize="12.5" FontWeight="SemiBold" TextWrapping="Wrap" MaxWidth="440" />
</StackPanel>
<TextBlock Style="{StaticResource AdminHintStyle}"
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}}">
<Run Text="Fichier de référence (sans secret, inutile sur un autre PC) :" />
<LineBreak />
<Run Text="{Binding CreatedKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
<LineBreak />
<Run Text="Clé publique :" />
<LineBreak />
<Run Text="{Binding PublicKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
</TextBlock>
<TextBlock Style="{StaticResource AdminHintStyle}"
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<Run Text="Clé privée (chiffrée, ne la transmettez jamais) :" />
<LineBreak />
<Run Text="{Binding CreatedKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
<LineBreak />
<Run Text="Clé publique :" />
<LineBreak />
<Run Text="{Binding PublicKeyPath, Mode=OneWay}" Foreground="{DynamicResource TextPrimaryBrush}" />
</TextBlock>
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" Margin="0,10,0,0" FontSize="12.5"
Content="Ouvrir le dossier" ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}"
Command="{Binding OpenKeyFolderCommand}" />
</StackPanel>
</Border>
<Button Style="{StaticResource AccentButtonStyle}" Height="44" HorizontalAlignment="Stretch" Margin="0,6,0,0"
Content="Continuer vers la connexion" ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}"
Command="{Binding ContinueCommand}" />
</StackPanel>
</Grid>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,38 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using GameLauncher.Modules.Admin.ViewModels;
namespace GameLauncher.Views.Admin;
/// <summary>
/// « J'ai une invitation ». Les phrases de passe restent dans les PasswordBox (jamais liées au
/// ViewModel) et sont effacées dès qu'elles ont été transmises.
/// </summary>
public partial class EnrollView : UserControl
{
public EnrollView()
{
InitializeComponent();
}
private async void OnSubmitClick(object sender, RoutedEventArgs e) => await SubmitAsync();
private async void OnConfirmationKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
e.Handled = true;
await SubmitAsync();
}
private async Task SubmitAsync()
{
if (DataContext is not EnrollViewModel { IsBusy: false, IsForm: true } vm) return;
var passphrase = PassphraseBox.Password;
var confirmation = ConfirmationBox.Password;
PassphraseBox.Clear();
ConfirmationBox.Clear();
await vm.SubmitAsync(passphrase, confirmation);
if (vm.IsForm && IsLoaded) PassphraseBox.Focus();
}
}
+135
View File
@@ -0,0 +1,135 @@
<UserControl x:Class="GameLauncher.Views.Admin.LoginView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls">
<!--
Connexion : la clé d'administrateur (fichier chiffré) signe un défi du serveur.
La phrase de passe ne quitte jamais cette vue : elle est passée à LoginAsync puis effacée.
-->
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False">
<StackPanel Width="470" Margin="0,30,0,30" HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- En-tête -->
<Border Width="64" Height="64" CornerRadius="20" HorizontalAlignment="Center" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{StaticResource Icon.Lock}" Fill="{DynamicResource OnAccentBrush}" Width="28" Height="28" Stretch="Uniform" />
</Border>
<TextBlock Text="Connexion administrateur" FontSize="22" FontWeight="SemiBold" Margin="0,18,0,0" HorizontalAlignment="Center" />
<TextBlock Margin="0,6,0,24" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="12.5" Foreground="{DynamicResource TextSecondaryBrush}"
Text="Votre clé signe un défi envoyé par le serveur : aucun mot de passe ne circule." />
<!-- Information (verrouillage, clé à faire approuver…) -->
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource InfoBrush}" Data="{StaticResource Icon.Info}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="400" />
</StackPanel>
</Border>
<!-- Clé -->
<DockPanel Margin="0,0,0,10">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" ToolTip="Actualiser la liste"
Command="{Binding RefreshCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.FolderKey}" ToolTip="Ouvrir le dossier des clés"
Command="{Binding OpenKeysFolderCommand}" />
</StackPanel>
<TextBlock Text="VOTRE CLÉ" Style="{StaticResource SectionTitleStyle}" Margin="0" VerticalAlignment="Center" />
</DockPanel>
<ListBox ItemsSource="{Binding Keys}" SelectedItem="{Binding SelectedKey, Mode=TwoWay}"
ItemContainerStyle="{StaticResource AdminKeyItemStyle}" MaxHeight="216"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto"
Visibility="{Binding HasKeys, Converter={StaticResource BoolToVisibility}}" />
<Border Style="{StaticResource AdminCardStyle}" Margin="0,0,0,8"
Visibility="{Binding HasKeys, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<StackPanel>
<TextBlock Text="Aucune clé trouvée" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0">
<Run Text="Dossier examiné :" />
<Run Text="{Binding KeysDirectory, Mode=OneWay}" />
<LineBreak />
<Run Text="Choisissez votre fichier .key, ou créez une clé avec « J'ai une invitation »." />
</TextBlock>
</StackPanel>
</Border>
<DockPanel Margin="2,2,0,20">
<!-- Clés de la puce uniquement (une clé fichier se supprime à la main) -->
<Button DockPanel.Dock="Right" Style="{StaticResource LinkButtonStyle}" FontSize="12.5"
Foreground="{DynamicResource DangerBrush}" Content="Supprimer cette clé de la puce"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding DeleteTpmKeyCommand}"
ToolTip="Efface définitivement la clé de la puce de sécurité de ce PC (après confirmation)"
Visibility="{Binding IsTpmKey, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" FontSize="12.5"
Content="Choisir un autre fichier de clé…" ui:IconAssist.Icon="{StaticResource Icon.FileKey}"
Command="{Binding BrowseKeyCommand}" />
</DockPanel>
<!-- Puce bloquée par sa protection anti-essais : temps d'attente -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowTpmLockout, Converter={StaticResource BoolToVisibility}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.ClockOutline}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Puce de sécurité bloquée" FontSize="12.5" FontWeight="SemiBold" />
<TextBlock Text="{Binding LockoutText}" FontSize="12.5" Margin="0,3,0,0" TextWrapping="Wrap" />
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" Margin="0,8,0,0" FontSize="12"
Content="Revérifier maintenant" ui:IconAssist.Icon="{StaticResource Icon.Refresh}"
Command="{Binding RecheckLockoutCommand}" />
</StackPanel>
</Grid>
</Border>
<!-- Phrase de passe -->
<TextBlock Text="{Binding SecretLabel}" Style="{StaticResource AdminFieldLabelStyle}" />
<PasswordBox x:Name="PassphraseBox" Style="{StaticResource ModernPasswordBoxStyle}" Margin="0,0,0,16"
IsEnabled="{Binding HasKeys}" KeyDown="OnPassphraseKeyDown" />
<!-- Erreur -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="400" />
</StackPanel>
</Border>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" Margin="0,0,0,12" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Height="44" HorizontalAlignment="Stretch" Content="Se connecter"
ui:IconAssist.Icon="{StaticResource Icon.Login}" IsEnabled="{Binding CanLogin}" Click="OnLoginClick" />
<!-- Invitation -->
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,26,0,18" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}" VerticalAlignment="Center">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.TicketAccount}" />
</Border>
<StackPanel Grid.Column="1" Margin="12,0,12,0" VerticalAlignment="Center">
<TextBlock Text="Nouvel administrateur ou nouveau PC ?" FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="Créez votre clé avec le code d'invitation reçu." Style="{StaticResource AdminHintStyle}" />
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource SmallButtonStyle}" Content="J'ai une invitation"
ui:IconAssist.Icon="{StaticResource Icon.KeyPlus}" Command="{Binding ShowEnrollCommand}" />
</Grid>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,40 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using GameLauncher.Modules.Admin.ViewModels;
namespace GameLauncher.Views.Admin;
/// <summary>
/// Écran de connexion. La phrase de passe reste dans le PasswordBox (jamais liée au ViewModel) :
/// elle est lue au moment de la connexion puis effacée, que la connexion réussisse ou non.
/// </summary>
public partial class LoginView : UserControl
{
public LoginView()
{
InitializeComponent();
Loaded += (_, _) =>
{
if (PassphraseBox.IsEnabled) PassphraseBox.Focus();
};
}
private async void OnLoginClick(object sender, RoutedEventArgs e) => await LoginAsync();
private async void OnPassphraseKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
e.Handled = true;
await LoginAsync();
}
private async Task LoginAsync()
{
if (DataContext is not LoginViewModel { CanLogin: true } vm) return;
var passphrase = PassphraseBox.Password;
PassphraseBox.Clear();
await vm.LoginAsync(passphrase);
if (IsLoaded && PassphraseBox.IsEnabled) PassphraseBox.Focus(); // échec : nouvel essai
}
}
+3 -1
View File
@@ -224,7 +224,9 @@
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Grid Width="42" Height="42">
<!-- Logo : 5 clics rapides ouvrent le menu d'administration (accès discret, voir MainWindow.xaml.cs) -->
<Grid x:Name="LogoBadge" Width="42" Height="42" Background="Transparent"
PreviewMouseLeftButtonDown="OnLogoMouseDown">
<Border CornerRadius="13" Background="{DynamicResource AccentGradientBrush}" Opacity="0.6">
<Border.Effect>
<BlurEffect Radius="16" />
+33
View File
@@ -16,9 +16,42 @@ public partial class MainWindow : Window, IThemeTransition
/// <summary>Largeur ajoutée à la fenêtre quand la liste des jeux est affichée.</summary>
public const double GameRailWidth = 88;
/// <summary>Clics sur le logo nécessaires pour ouvrir le menu d'administration, et délai pour les faire.</summary>
private const int AdminLogoClicks = 5;
private static readonly TimeSpan AdminLogoDelay = TimeSpan.FromSeconds(3);
private readonly Queue<DateTime> _logoClicks = new();
public MainWindow()
{
InitializeComponent();
PreviewKeyDown += OnPreviewKeyDown;
}
/// <summary>
/// Accès discret au menu d'administration : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo.
/// Discret ne veut pas dire protégé : seule la connexion par clé (vérifiée par le serveur) l'est.
/// </summary>
public event Action? AdminRequested;
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Avec Alt, WPF signale la touche dans SystemKey (Key.System).
var key = e.Key == Key.System ? e.SystemKey : e.Key;
if (key != Key.A || Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift | ModifierKeys.Alt)) return;
e.Handled = true;
AdminRequested?.Invoke();
}
private void OnLogoMouseDown(object sender, MouseButtonEventArgs e)
{
var now = DateTime.UtcNow;
while (_logoClicks.Count > 0 && now - _logoClicks.Peek() > AdminLogoDelay) _logoClicks.Dequeue();
_logoClicks.Enqueue(now);
if (_logoClicks.Count < AdminLogoClicks) return;
_logoClicks.Clear();
e.Handled = true; // pas de déplacement de la fenêtre pour ce clic
AdminRequested?.Invoke();
}
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre.</summary>
+6
View File
@@ -25,6 +25,12 @@
// tools\publish-launcher-update.bat. Supprimez la ligne pour désactiver l'auto-mise à jour.
"launcherUpdateUrl": "https://launcher.lanisys.fr/launcher/launcher-update.json",
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo) :
// adresse de l'API du serveur, en https. Par sécurité, la version Release ne la lit QUE dans le
// launcher.json intégré à l'exécutable (jamais dans un fichier posé à côté). Supprimez la ligne
// pour retirer le menu d'administration.
"apiUrl": "https://launcher.lanisys.fr/api/",
"websiteUrl": "https://lanisys.fr",
// Thème du launcher pour ce jeu : "default" (violet/cyan) ou "exovival" (couloir sombre, vert).
@@ -22,6 +22,12 @@
// de produit pour ces mises à jour : ne le changez plus une fois le launcher distribué.
"launcherUpdateUrl": "https://launcher.lanisys.fr/launcher/launcher-update.json",
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo) :
// adresse de l'API du serveur, en https. Par sécurité, la version Release ne la lit QUE dans le
// launcher.json intégré à l'exécutable (jamais dans un fichier posé à côté). Propriété de la racine
// uniquement (un seul serveur pour tous les jeux). Supprimez la ligne pour retirer le menu d'administration.
"apiUrl": "https://launcher.lanisys.fr/api/",
// Jeu affiché au premier lancement (ensuite : le dernier jeu sélectionné).
"defaultGame": "EZR",
@@ -0,0 +1,411 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Models;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Server.Cli;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
public class AdminKeyLocatorTests
{
[Fact]
public void Finds_encrypted_keys_with_their_fingerprint_and_never_the_launcher_signing_key()
{
using var folder = new TempFolder();
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using (signer)
{
AdminTestKit.CreateKeyFile(folder.Path, "launcher-signing").Signer.Dispose(); // clé de signature du launcher
File.WriteAllText(folder.File("notes.key"), "pas une clé");
using var other = new TempFolder();
var (elsewhere, otherSigner) = AdminTestKit.CreateKeyFile(other.Path, "portable");
otherSigner.Dispose();
File.Delete(Path.ChangeExtension(elsewhere, ".pub")); // .pub absent : empreinte inconnue
var keys = AdminKeyLocator.Find(folder.Path, new[] { elsewhere, path });
Assert.Equal(2, keys.Count);
Assert.Equal("admin", keys[0].Name);
Assert.Equal(signer.Fingerprint, keys[0].Fingerprint);
Assert.Equal(signer.KeyId, keys[0].KeyId);
Assert.Equal(Path.GetFullPath(elsewhere), keys[1].Path);
Assert.Null(keys[1].Fingerprint);
Assert.DoesNotContain(keys, k => k.Name == "launcher-signing" || k.Name == "notes");
Assert.True(AdminKeyLocator.IsReserved(folder.File("Launcher-Signing.key")));
Assert.True(AdminKeyLocator.IsReserved(folder.File("launcher-signing - Copie.key")));
Assert.True(AdminKeyLocator.IsReserved(folder.File("launcher-signing.key.")));
Assert.False(AdminKeyLocator.IsReserved(folder.File("admin.key")));
}
}
[Fact]
public void Suggests_a_free_file_name()
{
using var folder = new TempFolder();
Assert.Equal(folder.File("admin.key"), AdminKeyLocator.SuggestNewKeyPath(folder.Path));
AdminTestKit.CreateKeyFile(folder.Path).Signer.Dispose();
Assert.Equal(folder.File("admin-2.key"), AdminKeyLocator.SuggestNewKeyPath(folder.Path));
}
[Fact]
public void Invalid_remembered_paths_are_ignored()
{
using var folder = new TempFolder();
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
signer.Dispose();
var keys = AdminKeyLocator.Find(folder.Path, new[] { "C:\\bad\0path.key", "", path, path });
Assert.Equal(path, Assert.Single(keys).Path);
}
[Fact]
public void Missing_folder_is_not_an_error()
{
Assert.Empty(AdminKeyLocator.Find(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))));
}
}
public class AdminSettingsTests
{
[Fact]
public void Remembers_recent_keys_and_survives_a_corrupted_file()
{
using var folder = new TempFolder();
var path = folder.File("admin.json");
var settings = new AdminSettings();
for (var i = 0; i < 12; i++) settings.Remember(folder.File($"k{i}.key"));
settings.Remember(folder.File("k3.key"));
settings.Save(path);
var loaded = AdminSettings.Load(path);
Assert.Equal(folder.File("k3.key"), loaded.LastKeyPath);
Assert.Equal(10, loaded.KeyPaths.Count);
Assert.Equal(folder.File("k3.key"), loaded.KeyPaths[0]);
Assert.Single(loaded.KeyPaths, p => p == folder.File("k3.key"));
File.WriteAllText(path, "{ illisible");
Assert.Empty(AdminSettings.Load(path).KeyPaths);
}
}
public class AdminLoginAndEnrollmentTests
{
private static void Owner(TestApi api, IAdminSigner key, string name = "Nicolas") =>
Assert.Equal(0, AdminCli.Run(new[] { "add-owner", "--name", name, "--public-key", Convert.ToBase64String(key.PublicKey) },
TextWriter.Null, api.DataDirectory));
[Fact]
public async Task Login_with_a_key_file_and_logout_on_dispose()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using (signer) Owner(api, signer);
var connection = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase);
Assert.Equal("Nicolas", connection.Session.Profile.DisplayName);
Assert.Equal(signer.Fingerprint, connection.Fingerprint);
Assert.True(connection.LocalExpiresAt > DateTimeOffset.UtcNow + TimeSpan.FromHours(7));
await connection.DisposeAsync();
Assert.False(connection.Session.IsOpen);
await connection.DisposeAsync(); // deux fois : sans effet
}
[Fact]
public async Task Wrong_passphrase_and_launcher_signing_key_are_refused()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
signer.Dispose();
var service = new AdminLoginService(api.CreateClient(), "tests");
var wrong = await Assert.ThrowsAsync<CryptographicException>(() => service.LoginAsync(path, "mauvaise phrase de passe"));
Assert.Contains("Phrase de passe incorrecte", ApiErrorText.Describe(wrong));
await Assert.ThrowsAsync<InvalidOperationException>(() => service.LoginAsync(folder.File("launcher-signing.key"), AdminTestKit.Passphrase));
File.Copy(path, folder.File("launcher-signing - Copie.key"));
await Assert.ThrowsAsync<InvalidOperationException>(() => service.LoginAsync(folder.File("launcher-signing - Copie.key"), AdminTestKit.Passphrase));
}
[Fact]
public async Task Unknown_key_gets_a_clear_message()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
signer.Dispose();
var refused = await Assert.ThrowsAsync<ApiException>(() =>
new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase));
Assert.Contains("en attente d'approbation", ApiErrorText.Describe(refused, duringLogin: true));
}
[Fact]
public async Task Invitation_creates_the_key_file_and_a_pending_enrollment()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
using var folder = new TempFolder();
var created = await new AdminEnrollmentService(api.CreateClient())
.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, invitation.Code, "PC de Paul");
Assert.True(File.Exists(created.KeyPath));
Assert.True(File.Exists(created.PublicKeyPath));
Assert.NotNull(created.Enrollment);
Assert.Equal("Paul", created.Enrollment!.DisplayName);
Assert.Equal(created.Fingerprint, created.Enrollment.Fingerprint);
Assert.Contains("ENCRYPTED PRIVATE KEY", File.ReadAllText(created.KeyPath));
await owner.ApproveKeyAsync(created.Enrollment.AdminId, created.KeyId, created.Fingerprint);
await using var paul = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(created.KeyPath, AdminTestKit.Passphrase);
Assert.Equal("Paul", paul.Session.Profile.DisplayName);
}
[Fact]
public async Task Refused_invitation_leaves_no_key_file_behind()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var service = new AdminEnrollmentService(api.CreateClient());
await Assert.ThrowsAsync<ApiException>(() =>
service.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, InvitationCodes.Generate(), null));
Assert.Empty(Directory.GetFiles(folder.Path));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("a.key"), "courte", null, null));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("a.key"), AdminTestKit.Passphrase, "ABCD-0000", null));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.CreateKeyAsync(folder.File("launcher-signing.key"), AdminTestKit.Passphrase, null, null));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("a.txt"), AdminTestKit.Passphrase, null, null));
Assert.Empty(Directory.GetFiles(folder.Path));
}
[Fact]
public async Task Lost_server_answer_keeps_the_key_and_explains_what_to_do()
{
// Port fermé : la requête d'enregistrement n'obtient aucune réponse.
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
using var folder = new TempFolder();
using var client = new LauncherApiClient(new Uri($"http://127.0.0.1:{port}/api/"), "tests");
var created = await new AdminEnrollmentService(client)
.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, InvitationCodes.Generate(), null);
Assert.Null(created.Enrollment);
Assert.Contains("n'a pas confirmé", created.EnrollmentUnconfirmed);
Assert.True(File.Exists(created.KeyPath));
Assert.True(File.Exists(created.PublicKeyPath));
}
[Fact]
public void Key_files_are_never_overwritten_and_never_left_half_written()
{
using var folder = new TempFolder();
File.WriteAllText(folder.File("admin.pub"), "clé publique existante");
using var key = AdminKeyFile.Generate();
Assert.Throws<IOException>(() => AdminKeyFile.Save(key, folder.File("admin.key"), AdminTestKit.Passphrase));
Assert.False(File.Exists(folder.File("admin.key")));
Assert.Equal("clé publique existante", File.ReadAllText(folder.File("admin.pub")));
}
[Fact]
public async Task Key_without_invitation_is_only_created_locally_and_never_overwritten()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var service = new AdminEnrollmentService(api.CreateClient());
var created = await service.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, " ", "PC bureau");
Assert.Null(created.Enrollment);
Assert.Equal(AdminKeys.ComputeFingerprint(AdminKeys.ParsePublicKey(File.ReadAllText(created.PublicKeyPath))), created.Fingerprint);
await Assert.ThrowsAsync<IOException>(() => service.CreateKeyAsync(folder.File("admin.key"), AdminTestKit.Passphrase, null, null));
}
}
public class AdminSessionGuardTests
{
private static async Task<(TestApi Api, AdminConnection Connection, EcdsaAdminSigner Key)> ConnectAsync()
{
var api = await TestApi.StartAsync();
var folder = Path.Combine(Path.GetTempPath(), "launcher-admin-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var (path, signer) = AdminTestKit.CreateKeyFile(folder);
Assert.Equal(0, AdminCli.Run(new[] { "add-owner", "--name", "Nicolas", "--public-key", Convert.ToBase64String(signer.PublicKey) },
TextWriter.Null, api.DataDirectory));
var connection = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase);
return (api, connection, signer);
}
[Fact]
public async Task Locks_after_inactivity_and_activity_postpones_it()
{
var (api, connection, key) = await ConnectAsync();
await using var _ = api;
await using var __ = connection;
using var ___ = key;
using var guard = new AdminSessionGuard(connection, api.Time, ImmediateDispatcher.Instance, TimeSpan.FromMinutes(10), startTimer: false);
string? reason = null;
guard.LockRequested = r => { reason = r; return Task.CompletedTask; };
api.Time.Advance(TimeSpan.FromMinutes(8));
guard.NotifyActivity();
api.Time.Advance(TimeSpan.FromMinutes(8));
await guard.TickAsync();
Assert.Null(reason);
Assert.InRange(guard.Remaining, TimeSpan.FromMinutes(1.9), TimeSpan.FromMinutes(2.1));
api.Time.Advance(TimeSpan.FromMinutes(2));
await guard.TickAsync();
Assert.Contains("inactivité", reason);
}
[Fact]
public async Task Keeps_the_server_session_alive_while_the_admin_is_active()
{
var (api, connection, key) = await ConnectAsync();
await using var _ = api;
await using var __ = connection;
using var ___ = key;
using var guard = new AdminSessionGuard(connection, api.Time, ImmediateDispatcher.Instance, TimeSpan.FromMinutes(10), startTimer: false);
string? reason = null;
guard.LockRequested = r => { reason = r; return Task.CompletedTask; };
// 40 minutes d'activité sans aucune requête de l'administrateur : le serveur (20 min d'inactivité) garde la session.
for (var i = 0; i < 16; i++)
{
api.Time.Advance(TimeSpan.FromMinutes(2.5));
guard.NotifyActivity();
await guard.TickAsync();
}
Assert.Null(reason);
await connection.Session.RefreshAsync();
}
[Fact]
public async Task Locks_when_the_server_revokes_the_session_or_the_maximum_duration_is_reached()
{
var (api, connection, key) = await ConnectAsync();
await using var _ = api;
await using var __ = connection;
using var ___ = key;
using var guard = new AdminSessionGuard(connection, api.Time, ImmediateDispatcher.Instance, TimeSpan.FromMinutes(10), startTimer: false);
string? reason = null;
guard.LockRequested = r => { reason = r; return Task.CompletedTask; };
Assert.Equal(0, AdminCli.Run(new[] { "revoke-key", "--key", key.KeyId }, TextWriter.Null, api.DataDirectory));
api.Time.Advance(TimeSpan.FromMinutes(5));
guard.NotifyActivity();
await guard.TickAsync();
Assert.Contains("révoquée", reason);
var (api2, connection2, key2) = await ConnectAsync();
await using var ____ = api2;
await using var _____ = connection2;
using var ______ = key2;
using var guard2 = new AdminSessionGuard(connection2, api2.Time, ImmediateDispatcher.Instance, TimeSpan.FromHours(24), startTimer: false);
string? reason2 = null;
guard2.LockRequested = r => { reason2 = r; return Task.CompletedTask; };
api2.Time.Advance(TimeSpan.FromHours(8) + TimeSpan.FromMinutes(1));
await guard2.TickAsync();
Assert.Contains("durée maximale", reason2);
}
}
public class ModuleCatalogTests
{
private sealed class TestModule : ILauncherModule
{
private readonly AdminPanelDescriptor[] _panels;
public TestModule(string id, params AdminPanelDescriptor[] panels)
{
Id = id;
_panels = panels;
}
public string Id { get; }
public string DisplayName => Id;
public void Register(IModuleRegistry registry)
{
foreach (var panel in _panels) registry.AddAdminPanel(panel);
}
}
private static AdminPanelDescriptor Panel(string id, string? permission, int order) =>
new(id, id, "", "Info", permission, order, _ => throw new NotSupportedException());
[Fact]
public void Panels_are_ordered_and_filtered_by_permission()
{
var catalog = new ModuleCatalog(new ILauncherModule[]
{
new AdminModule(),
new TestModule("tests", Panel("tests.admins", AdminPermissions.AdminsManage, 50), Panel("tests.games", AdminPermissions.GamesEdit, 10))
});
Assert.Equal(new[] { "admin.dashboard", "tests.games", "tests.admins" }, catalog.AdminPanels.Select(p => p.Id));
Assert.Equal(new[] { "admin.dashboard", "tests.games" },
catalog.AdminPanelsFor(AdminRoles.PermissionsOf(AdminRoles.Editor).ToList()).Select(p => p.Id));
}
[Fact]
public void Invalid_declarations_are_refused()
{
Assert.Throws<InvalidOperationException>(() => new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new AdminModule() }));
Assert.Throws<InvalidOperationException>(() => new ModuleCatalog(new ILauncherModule[]
{ new TestModule("a", Panel("x", null, 0)), new TestModule("b", Panel("X", null, 1)) }));
Assert.Throws<InvalidOperationException>(() => new ModuleCatalog(new ILauncherModule[]
{ new TestModule("a", Panel("y", "sans-point", 0)) }));
}
}
public class LauncherApiUrlTests
{
private const string Game = "\"gameId\": \"EZR\", \"gameName\": \"Jeu\", \"manifestUrl\": \"https://launcher.example/ezr/manifest.json\"";
[Theory]
[InlineData("https://launcher.example/api", "https://launcher.example/api/")]
[InlineData("https://launcher.example/api/", "https://launcher.example/api/")]
[InlineData("http://127.0.0.1:5080/api/", "http://127.0.0.1:5080/api/")]
public void Api_url_is_read_and_normalized(string configured, string expected)
{
Assert.Equal(expected, LauncherCatalog.Parse($"{{ {Game}, \"apiUrl\": \"{configured}\" }}").ApiUrl);
Assert.Equal(expected, LauncherCatalog.Parse($"{{ \"apiUrl\": \"{configured}\", \"games\": [ {{ {Game} }} ] }}").ApiUrl);
}
[Theory]
[InlineData("http://launcher.example/api/")]
[InlineData("https://launcher.example/api/?redirect=evil")]
[InlineData("https://user:pass@launcher.example/api/")]
[InlineData("ftp://launcher.example/api/")]
[InlineData("api/")]
public void Unsafe_api_urls_are_ignored_without_breaking_the_launcher(string configured)
{
var catalog = LauncherCatalog.Parse($"{{ {Game}, \"apiUrl\": \"{configured}\" }}");
Assert.Null(catalog.ApiUrl);
Assert.Contains("apiUrl ignorée", catalog.ApiUrlError);
Assert.Single(catalog.Games);
var notText = LauncherCatalog.Parse($"{{ {Game}, \"apiUrl\": 42 }}");
Assert.Null(notText.ApiUrl);
Assert.NotNull(notText.ApiUrlError);
}
[Fact]
public void Without_api_url_the_admin_menu_is_disabled()
{
var catalog = LauncherCatalog.Parse($"{{ {Game} }}");
Assert.Null(catalog.ApiUrl);
Assert.Null(catalog.ApiUrlError);
}
}
@@ -0,0 +1,205 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Models;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Server.Cli;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Boîtes de dialogue simulées : réponses programmées, actions enregistrées.</summary>
public sealed class FakeDialogs : IModuleDialogs
{
public string? NextOpenFile { get; set; }
public string? NextSaveFile { get; set; }
public List<string> Messages { get; } = new();
public string? Clipboard { get; private set; }
public List<string> OpenedFolders { get; } = new();
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) => Messages.Add($"{title} : {message}");
/// <summary>Réponse aux confirmations (vrai par défaut).</summary>
public bool ConfirmAnswer { get; set; } = true;
public List<string> Confirmations { get; } = new();
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false)
{
Confirmations.Add($"{title} : {message}");
return ConfirmAnswer;
}
public string? PickOpenFile(string title, string filter, string? initialDirectory) => NextOpenFile;
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => NextSaveFile;
public void CopyToClipboard(string text) => Clipboard = text;
public void OpenFolder(string path) => OpenedFolders.Add(path);
}
/// <summary>Dossier temporaire (clés, préférences) supprimé à la fin du test.</summary>
public sealed class TempFolder : IDisposable
{
public TempFolder() => Directory.CreateDirectory(Path);
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "launcher-admin-tests", Guid.NewGuid().ToString("N"));
public string File(string name) => System.IO.Path.Combine(Path, name);
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (IOException)
{
}
}
}
public static class AdminTestKit
{
public const string Passphrase = "phrase de passe solide";
/// <summary>Crée un fichier de clé (comme tools\admin-key.bat) et renvoie un signataire équivalent.</summary>
public static (string Path, EcdsaAdminSigner Signer) CreateKeyFile(string directory, string name = "admin")
{
var path = System.IO.Path.Combine(directory, name + ".key");
using (var key = AdminKeyFile.Generate()) AdminKeyFile.Save(key, path, Passphrase);
return (path, new EcdsaAdminSigner(AdminKeyFile.Load(path, Passphrase)));
}
/// <summary>Déclare le propriétaire avec la CLI du serveur (comme lors de l'installation).</summary>
public static void RegisterOwner(TestApi api, IAdminSigner key, string name = "Nicolas") =>
Assert.Equal(0, AdminCli.Run(new[] { "add-owner", "--name", name, "--public-key", Convert.ToBase64String(key.PublicKey) },
TextWriter.Null, api.DataDirectory));
public static LauncherConfig Game(string id = "EZR", string name = "Exovival Zombie Reborn") => new()
{
GameId = id,
GameName = name,
Publisher = "LanisysGames",
ManifestUrl = $"https://launcher.example/{id.ToLowerInvariant()}/manifest.json"
};
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
IAdminTpm? tpm = null) => new()
{
Tpm = tpm,
Client = api.CreateClient(),
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
Games = new[] { Game() },
Dialogs = dialogs,
Ui = ImmediateDispatcher.Instance,
SettingsPath = System.IO.Path.Combine(folder, "admin.json"),
KeysDirectory = folder,
LauncherVersion = "1.4.0",
Time = api.Time,
StartTimers = false
};
}
/// <summary>
/// Puce TPM simulée : clés en mémoire « non exportables », code PIN vérifié à chaque ouverture,
/// blocage après 3 codes faux (comme la protection anti-essais d'une vraie puce).
/// </summary>
public sealed class FakeTpm : IAdminTpm
{
private readonly Dictionary<string, (ECDsa Key, string Pin)> _keys = new();
private int _failures;
public TpmAvailability Availability { get; set; } = new(true, "Puce de sécurité TPM 2.0 disponible (simulée).");
/// <summary>Vrai : les signatures échouent (puce bloquée après la création de la clé…).</summary>
public bool FailSigning { get; set; }
public const int MaxTries = 3;
public TimeSpan RecoveryInterval { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>Faux : l'état de la protection anti-essais n'est pas lisible (réservé aux administrateurs…).</summary>
public bool LockoutReadable { get; set; } = true;
public int Failures => _failures;
/// <summary>Simule le temps qui passe : la puce oublie une erreur.</summary>
public void Heal() => _failures = Math.Max(0, _failures - 1);
public TpmLockoutInfo? GetLockoutInfo() =>
LockoutReadable ? new TpmLockoutInfo(_failures, MaxTries, RecoveryInterval, _failures >= MaxTries) : null;
public int Checks { get; private set; }
public int OpenSigners { get; private set; }
public IReadOnlyCollection<string> KeyNames => _keys.Keys;
public TpmAvailability Check()
{
Checks++;
return Availability;
}
public IDisposableAdminSigner CreateKey(string keyName, string pin)
{
if (!Availability.IsAvailable) throw new CryptographicException("Puce indisponible.");
if (!AdminTpmKeyFile.IsValidKeyName(keyName)) throw new ArgumentException("nom");
var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
_keys.Add(keyName, (key, pin));
return new Signer(this, key);
}
public IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin)
{
if (!_keys.TryGetValue(keyName, out var entry))
throw new CryptographicException("Clé introuvable dans la puce de ce PC.");
if (!entry.Key.ExportSubjectPublicKeyInfo().AsSpan().SequenceEqual(expectedPublicKey))
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence.");
if (_failures >= MaxTries) throw new TpmPinException("Puce temporairement bloquée (trop de codes PIN faux).", isLockedOut: true);
if (entry.Pin != pin)
{
_failures++;
throw new TpmPinException("Code PIN incorrect.", isLockedOut: false);
}
_failures = 0;
return new Signer(this, entry.Key);
}
public void DeleteKey(string keyName) => _keys.Remove(keyName);
/// <summary>Remplace la clé d'un nom par une autre (simule un fichier de référence trompeur).</summary>
public void Swap(string keyName)
{
var (_, pin) = _keys[keyName];
_keys[keyName] = (ECDsa.Create(ECCurve.NamedCurves.nistP256), pin);
}
private sealed class Signer : IDisposableAdminSigner
{
private readonly FakeTpm _tpm;
private readonly ECDsa _key;
private bool _disposed;
public Signer(FakeTpm tpm, ECDsa key)
{
_tpm = tpm;
_key = key;
PublicKey = key.ExportSubjectPublicKeyInfo();
KeyId = AdminKeys.ComputeKeyId(PublicKey);
tpm.OpenSigners++;
}
public byte[] PublicKey { get; }
public string KeyId { get; }
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_tpm.FailSigning) throw new CryptographicException("Puce temporairement bloquée.");
return ValueTask.FromResult(AdminKeys.Sign(_key, message));
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_tpm.OpenSigners--;
}
}
}
@@ -0,0 +1,380 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Clés conservées dans la puce de sécurité (TPM), avec une puce simulée.</summary>
public class AdminTpmTests
{
private const string Pin = "482915";
[Fact]
public async Task Tpm_key_is_enrolled_approved_and_used_to_log_in()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
using var folder = new TempFolder();
var tpm = new FakeTpm();
var created = await new AdminEnrollmentService(api.CreateClient(), tpm)
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, invitation.Code, "PC de Paul", useTpm: true);
Assert.True(created.IsTpm);
Assert.NotNull(created.Enrollment);
Assert.Single(tpm.KeyNames);
Assert.Equal(0, tpm.OpenSigners); // poignée de la clé fermée après l'enregistrement
var reference = AdminTpmKeyFile.Load(created.KeyPath);
Assert.Equal(created.Fingerprint, AdminKeys.ComputeFingerprint(reference.PublicKey));
Assert.Equal(created.Fingerprint,
AdminKeys.ComputeFingerprint(AdminKeys.ParsePublicKey(File.ReadAllText(created.PublicKeyPath))));
Assert.DoesNotContain("PRIVATE", File.ReadAllText(created.KeyPath)); // aucun secret dans la référence
var entry = Assert.Single(AdminKeyLocator.Find(folder.Path));
Assert.True(entry.IsTpm);
Assert.Equal("Code PIN de la clé (puce TPM)", entry.SecretLabel);
Assert.Equal(created.Fingerprint, entry.Fingerprint);
await owner.ApproveKeyAsync(created.Enrollment!.AdminId, created.KeyId, created.Fingerprint);
var connection = await new AdminLoginService(api.CreateClient(), "tests", tpm).LoginAsync(created.KeyPath, Pin);
Assert.True(connection.IsTpm);
Assert.Equal("Paul", connection.Session.Profile.DisplayName);
Assert.Equal(1, tpm.OpenSigners);
await connection.DisposeAsync();
Assert.Equal(0, tpm.OpenSigners); // verrouillage : poignée de la clé libérée
}
[Fact]
public async Task Wrong_pin_missing_tpm_and_tampered_reference_are_refused()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var tpm = new FakeTpm();
var created = await new AdminEnrollmentService(api.CreateClient(), tpm)
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true);
Assert.Null(created.Enrollment);
var login = new AdminLoginService(api.CreateClient(), "tests", tpm);
var wrong = await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, "000000"));
Assert.False(wrong.IsLockedOut);
Assert.Contains("Code PIN incorrect", wrong.Message);
await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, "000000"));
await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, "000000"));
var locked = await Assert.ThrowsAsync<TpmPinException>(() => login.LoginAsync(created.KeyPath, Pin));
Assert.True(locked.IsLockedOut);
Assert.Contains("bloquée", locked.Message);
// Sans puce utilisable : message clair, pas de plantage.
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new AdminLoginService(api.CreateClient(), "tests").LoginAsync(created.KeyPath, Pin));
// Référence qui désigne une autre clé que celle annoncée : refusée avant tout contact avec le serveur.
var other = new FakeTpm();
var reference = AdminTpmKeyFile.Load(created.KeyPath);
other.CreateKey(reference.KeyName, Pin).Dispose();
other.Swap(reference.KeyName);
var mismatch = await Assert.ThrowsAsync<CryptographicException>(() =>
new AdminLoginService(api.CreateClient(), "tests", other).LoginAsync(created.KeyPath, Pin));
Assert.Contains("ne correspond pas", mismatch.Message);
Assert.Equal(0, other.OpenSigners);
}
[Fact]
public async Task Refused_invitation_removes_the_key_from_the_chip_and_the_disk()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var tpm = new FakeTpm();
var service = new AdminEnrollmentService(api.CreateClient(), tpm);
await Assert.ThrowsAsync<ApiException>(() =>
service.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, InvitationCodes.Generate(), null, useTpm: true));
Assert.Empty(tpm.KeyNames);
Assert.Empty(Directory.GetFiles(folder.Path));
Assert.Equal(0, tpm.OpenSigners);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("admin.tpmkey"), "12345", null, null, useTpm: true));
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateKeyAsync(folder.File("admin.key"), Pin, null, null, useTpm: true));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new AdminEnrollmentService(api.CreateClient()).CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true));
Assert.Empty(tpm.KeyNames);
Assert.Empty(Directory.GetFiles(folder.Path));
}
[Fact]
public async Task Chip_failing_to_sign_the_proof_leaves_nothing_behind()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
using var folder = new TempFolder();
var tpm = new FakeTpm { FailSigning = true };
await Assert.ThrowsAsync<CryptographicException>(() => new AdminEnrollmentService(api.CreateClient(), tpm)
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, invitation.Code, null, useTpm: true));
Assert.Empty(tpm.KeyNames);
Assert.Empty(Directory.GetFiles(folder.Path));
// Rien n'a été envoyé : l'invitation reste utilisable.
tpm.FailSigning = false;
var created = await new AdminEnrollmentService(api.CreateClient(), tpm)
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, invitation.Code, null, useTpm: true);
Assert.NotNull(created.Enrollment);
}
[Fact]
public async Task File_and_chip_keys_never_share_a_public_key_file()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
AdminTestKit.CreateKeyFile(folder.Path).Signer.Dispose();
File.Delete(folder.File("admin.pub"));
var tpm = new FakeTpm();
await Assert.ThrowsAsync<IOException>(() => new AdminEnrollmentService(api.CreateClient(), tpm)
.CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true));
Assert.Empty(tpm.KeyNames);
Assert.False(AdminTpmKeyFile.IsValidKeyName(AdminTpmKeyFile.NewKeyName() + "\n"));
}
[Fact]
public void Reference_files_are_validated_and_names_never_collide()
{
using var folder = new TempFolder();
var publicKey = ECDsa.Create(ECCurve.NamedCurves.nistP256).ExportSubjectPublicKeyInfo();
Assert.Throws<ArgumentException>(() => AdminTpmKeyFile.Save(folder.File("a.tpmkey"), "Microsoft Passport Key", publicKey));
AdminTpmKeyFile.Save(folder.File("admin.tpmkey"), AdminTpmKeyFile.NewKeyName(), publicKey);
Assert.Throws<IOException>(() => AdminTpmKeyFile.Save(folder.File("admin.tpmkey"), AdminTpmKeyFile.NewKeyName(), publicKey));
Assert.Equal(folder.File("admin-2.key"), AdminKeyLocator.SuggestNewKeyPath(folder.Path)); // admin.pub déjà pris
Assert.Equal(folder.File("admin-2.tpmkey"), AdminKeyLocator.SuggestNewKeyPath(folder.Path, tpm: true));
File.WriteAllText(folder.File("autre.tpmkey"), "{ \"format\": \"gamelauncher-admin-tpm-key\", \"version\": 1, " +
"\"keyName\": \"../../clé\", \"publicKey\": \"\" }");
Assert.Throws<CryptographicException>(() => AdminTpmKeyFile.Load(folder.File("autre.tpmkey")));
Assert.Single(AdminKeyLocator.Find(folder.Path)); // fichier invalide ignoré
}
[Fact]
public async Task Chip_choice_stays_disabled_until_the_chip_is_really_checked()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var tpm = new FakeTpm { Availability = new TpmAvailability(false, "Aucune puce TPM 2.0 utilisable sur ce PC.") };
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
shell.ShowEnroll();
var enroll = Assert.IsType<EnrollViewModel>(shell.Screen);
await enroll.TpmCheck;
Assert.False(enroll.CanChooseTpm);
Assert.Equal("Aucune puce TPM 2.0 utilisable sur ce PC.", enroll.TpmStatus);
enroll.UseTpm = true;
Assert.False(enroll.UseTpm);
Assert.EndsWith(".key", enroll.KeyPath);
// Sans puce du tout (autre système) : même chose.
await using var noTpm = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
noTpm.ShowEnroll();
var plain = Assert.IsType<EnrollViewModel>(noTpm.Screen);
await plain.TpmCheck;
Assert.False(plain.CanChooseTpm);
Assert.Equal(TpmAvailability.Unsupported.Message, plain.TpmStatus);
}
[Fact]
public async Task Invitation_screen_creates_a_chip_key_and_login_asks_for_its_pin()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Admin });
using var folder = new TempFolder();
var tpm = new FakeTpm();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
shell.ShowEnroll();
var enroll = Assert.IsType<EnrollViewModel>(shell.Screen);
Assert.True(enroll.IsCheckingTpm || enroll.IsTpmAvailable);
await enroll.TpmCheck;
Assert.True(enroll.CanChooseTpm);
Assert.Equal(1, tpm.Checks);
enroll.UseTpm = true;
Assert.EndsWith(".tpmkey", enroll.KeyPath);
Assert.Contains("Code PIN", enroll.SecretLabel);
enroll.InvitationCode = invitation.Code;
await enroll.SubmitAsync("1234", "1234");
Assert.Contains("au moins 6", enroll.ErrorMessage);
await enroll.SubmitAsync(Pin, "999999");
Assert.Contains("codes PIN sont différents", enroll.ErrorMessage);
await enroll.SubmitAsync(Pin, Pin);
Assert.True(enroll.IsDone, enroll.ErrorMessage);
Assert.True(enroll.IsTpmKey);
Assert.True(enroll.IsEnrolled);
Assert.False(enroll.CanChooseTpm);
var paul = (await owner.ListAdminsAsync()).Single(a => a.DisplayName == "Paul");
await owner.ApproveKeyAsync(paul.Id, paul.Keys.Single().KeyId, enroll.Fingerprint);
enroll.ContinueCommand.Execute(null);
var login = Assert.IsType<LoginViewModel>(shell.Screen);
Assert.True(login.IsTpmKey);
Assert.Equal("Code PIN de la clé (puce TPM)", login.SecretLabel);
await login.LoginAsync("111111");
Assert.Contains("Code PIN incorrect", login.ErrorMessage);
await login.LoginAsync(Pin);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
Assert.EndsWith("· puce TPM", workspace.KeyLabel);
await workspace.LockCommand.ExecuteAsync();
Assert.Equal(0, tpm.OpenSigners);
// La vérification de la puce n'est faite qu'une fois par fenêtre.
shell.ShowEnroll();
await Assert.IsType<EnrollViewModel>(shell.Screen).TpmCheck;
Assert.Equal(1, tpm.Checks);
}
}
/// <summary>Écran de connexion : suppression d'une clé TPM et aide en cas de blocage de la puce.</summary>
public class AdminTpmLoginScreenTests
{
private const string Pin = "482915";
private static async Task<(AdminKeyCreation Key, string Folder)> CreateTpmKeyAsync(TestApi api, FakeTpm tpm, TempFolder folder) =>
(await new AdminEnrollmentService(api.CreateClient(), tpm).CreateKeyAsync(folder.File("admin.tpmkey"), Pin, null, null, useTpm: true),
folder.Path);
[Fact]
public async Task Tpm_key_can_be_deleted_from_the_login_screen_after_confirmation()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var tpm = new FakeTpm();
var (created, _) = await CreateTpmKeyAsync(api, tpm, folder);
var (filePath, fileSigner) = AdminTestKit.CreateKeyFile(folder.Path, "fichier");
fileSigner.Dispose();
var dialogs = new FakeDialogs { ConfirmAnswer = false };
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, tpm: tpm));
var login = Assert.IsType<LoginViewModel>(shell.Screen);
login.SelectedKey = login.Keys.Single(k => k.Path == filePath);
Assert.False(login.DeleteTpmKeyCommand.CanExecute(null)); // clés fichier : à supprimer à la main
login.SelectedKey = login.Keys.Single(k => k.IsTpm);
Assert.True(login.DeleteTpmKeyCommand.CanExecute(null));
await login.DeleteTpmKeyCommand.ExecuteAsync(); // annulé
Assert.Single(tpm.KeyNames);
Assert.True(File.Exists(created.KeyPath));
Assert.Contains(created.KeyId, Assert.Single(dialogs.Confirmations));
dialogs.ConfirmAnswer = true;
await login.DeleteTpmKeyCommand.ExecuteAsync();
Assert.Empty(tpm.KeyNames);
Assert.False(File.Exists(created.KeyPath));
Assert.False(File.Exists(created.PublicKeyPath));
Assert.Contains("revoke-key", login.StatusMessage);
Assert.Contains(created.KeyId, login.StatusMessage);
Assert.Equal(filePath, Assert.Single(login.Keys).Path); // la clé fichier reste
Assert.DoesNotContain(created.KeyPath, shell.Settings.KeyPaths);
}
[Fact]
public async Task Wrong_pins_show_remaining_tries_then_a_countdown_until_the_chip_accepts_codes_again()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var tpm = new FakeTpm();
await CreateTpmKeyAsync(api, tpm, folder);
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
var login = Assert.IsType<LoginViewModel>(shell.Screen);
await login.LockoutCheck;
Assert.False(login.ShowTpmLockout);
await login.LoginAsync("000000");
Assert.Contains("Encore 2 essai(s)", login.ErrorMessage);
await login.LoginAsync("000000");
Assert.Contains("Encore 1 essai(s)", login.ErrorMessage);
await login.LoginAsync("000000");
Assert.True(login.ShowTpmLockout);
Assert.False(login.HasError);
Assert.Contains("dans 10 min au plus", login.LockoutText);
Assert.False(login.CanLogin);
api.Time.Advance(TimeSpan.FromMinutes(4));
await login.TickLockoutAsync();
Assert.Contains("dans 6 min au plus", login.LockoutText);
Assert.False(login.CanLogin);
api.Time.Advance(TimeSpan.FromMinutes(6));
tpm.Heal(); // la puce a oublié une erreur
await login.TickLockoutAsync();
Assert.False(login.ShowTpmLockout);
Assert.True(login.CanLogin);
Assert.Contains("accepte de nouveau", login.StatusMessage);
}
[Fact]
public async Task Locked_chip_is_shown_as_soon_as_a_tpm_key_is_selected_even_without_a_known_duration()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var tpm = new FakeTpm();
var (created, _) = await CreateTpmKeyAsync(api, tpm, folder);
var reference = AdminTpmKeyFile.Load(created.KeyPath);
for (var i = 0; i < FakeTpm.MaxTries; i++)
Assert.Throws<TpmPinException>(() => tpm.OpenKey(reference.KeyName, reference.PublicKey, "000000"));
await using (var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm)))
{
var login = Assert.IsType<LoginViewModel>(shell.Screen);
await login.LockoutCheck;
Assert.True(login.ShowTpmLockout);
Assert.Contains("au plus", login.LockoutText);
}
// État illisible sur ce PC : explication générale, et nouvel essai permis.
tpm.LockoutReadable = false;
await using var other = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), tpm: tpm));
var screen = Assert.IsType<LoginViewModel>(other.Screen);
await screen.LockoutCheck;
Assert.False(screen.ShowTpmLockout); // rien à signaler tant que la puce n'a rien refusé
await screen.LoginAsync(Pin);
Assert.True(screen.ShowTpmLockout);
Assert.Contains("quelques minutes à quelques heures", screen.LockoutText);
Assert.True(screen.CanLogin);
}
[Theory]
[InlineData(45, "45 s")]
[InlineData(600, "10 min")]
[InlineData(570, "9 min 30 s")]
[InlineData(7200, "2 h")]
[InlineData(5430, "1 h 31 min")]
public void Waits_are_readable(int seconds, string expected)
{
Assert.Equal(expected, LoginViewModel.FormatWait(TimeSpan.FromSeconds(seconds)));
}
[Fact]
public void Lockout_estimate_follows_the_chip_counter()
{
var info = new TpmLockoutInfo(34, 32, TimeSpan.FromMinutes(10), InLockout: true);
Assert.True(info.IsLockedOut);
Assert.Equal(0, info.RemainingTries);
Assert.Equal(TimeSpan.FromMinutes(30), info.MaxWait);
Assert.Equal(TimeSpan.Zero, new TpmLockoutInfo(5, 32, TimeSpan.FromMinutes(10), false).MaxWait);
Assert.False(new TpmLockoutInfo(40, 32, TimeSpan.Zero, true).HealsWithTime);
}
}
@@ -0,0 +1,249 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
public class AdminViewModelTests
{
[Fact]
public async Task Login_opens_the_workspace_and_the_dashboard_shows_pending_keys()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
await using (var owner = await api.LoginAsync(signer))
{
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
using var paul = TestApi.NewKey();
await api.CreateClient().EnrollAsync(invitation.Code, paul, "PC de Paul");
}
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
var login = Assert.IsType<LoginViewModel>(shell.Screen);
Assert.Equal(path, login.SelectedKey!.Path);
Assert.False(shell.IsLoggedIn);
await login.LoginAsync("mauvaise phrase de passe");
Assert.Contains("Phrase de passe incorrecte", login.ErrorMessage);
Assert.Same(login, shell.Screen);
await login.LoginAsync(AdminTestKit.Passphrase);
Assert.True(shell.IsLoggedIn);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
Assert.Equal("Nicolas", workspace.DisplayName);
Assert.Equal("N", workspace.Initials);
Assert.Equal(DashboardPanelViewModel.PanelId, workspace.SelectedPanel!.Descriptor.Id);
Assert.StartsWith("Verrouillage dans 10 min", workspace.SessionText);
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel.ViewModel);
Assert.False(dashboard.HasError, dashboard.ErrorMessage);
Assert.NotEqual("…", dashboard.ServerVersion);
Assert.Equal(8, dashboard.ServerId.Length);
Assert.True(dashboard.CanManageAdmins);
Assert.Equal(1, dashboard.PendingApprovals);
Assert.StartsWith("1 clé attend votre approbation", dashboard.PendingText);
Assert.Equal("launcher.example", Assert.Single(dashboard.Games).ManifestHost);
Assert.Equal("1.4.0", dashboard.LauncherVersion);
// La clé utilisée est mémorisée pour la prochaine ouverture.
Assert.Equal(path, AdminSettings.Load(Path.Combine(folder.Path, "admin.json")).LastKeyPath);
}
[Fact]
public async Task Locking_closes_the_server_session_and_preselects_the_key()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
AdminTestKit.CreateKeyFile(folder.Path, "autre").Signer.Dispose();
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path, "nicolas");
using (signer) AdminTestKit.RegisterOwner(api, signer);
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
var login = Assert.IsType<LoginViewModel>(shell.Screen);
login.SelectedKey = login.Keys.Single(k => k.Name == "nicolas");
await login.LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
var session = workspace.Session;
await workspace.LockCommand.ExecuteAsync();
var relock = Assert.IsType<LoginViewModel>(shell.Screen);
Assert.Equal("Session verrouillée.", relock.StatusMessage);
Assert.Equal(path, relock.SelectedKey!.Path);
Assert.False(session.IsOpen);
// Un rapport tardif de l'ancienne session ne touche pas l'écran courant.
workspace.ReportSessionLost("trop tard");
Assert.Same(relock, shell.Screen);
}
[Fact]
public async Task Inactivity_returns_to_the_login_screen()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using (signer) AdminTestKit.RegisterOwner(api, signer);
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
api.Time.Advance(TimeSpan.FromMinutes(11));
await workspace.Guard.TickAsync();
Assert.Contains("inactivité", Assert.IsType<LoginViewModel>(shell.Screen).StatusMessage);
Assert.False(workspace.Session.IsOpen);
}
[Fact]
public async Task Editors_only_see_the_panels_they_are_allowed_to_use()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
using var folder = new TempFolder();
var created = await new AdminEnrollmentService(api.CreateClient()).CreateKeyAsync(folder.File("paul.key"), AdminTestKit.Passphrase, invitation.Code, null);
await owner.ApproveKeyAsync(created.Enrollment!.AdminId, created.KeyId, created.Fingerprint);
var modules = new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new ExtraPanels() });
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
Assert.Equal(new[] { DashboardPanelViewModel.PanelId, "extra.notes" }, workspace.PanelItems.Select(p => p.Descriptor.Id));
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel!.ViewModel);
Assert.False(dashboard.CanManageAdmins);
Assert.Equal(0, dashboard.PendingApprovals);
Assert.False(dashboard.HasError, dashboard.ErrorMessage);
workspace.Navigate("extra.admins"); // non autorisé : ignoré
Assert.Equal(DashboardPanelViewModel.PanelId, workspace.SelectedPanel.Descriptor.Id);
workspace.Navigate("EXTRA.NOTES");
await workspace.Activation;
Assert.Equal("extra.notes", workspace.SelectedPanel.Descriptor.Id);
Assert.Equal(1, Assert.IsType<CountingPanel>(workspace.SelectedPanel.ViewModel).Activations);
}
[Fact]
public async Task Invitation_screen_creates_the_key_then_returns_to_login()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Admin });
using var folder = new TempFolder();
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
Assert.False(Assert.IsType<LoginViewModel>(shell.Screen).HasKeys);
shell.ShowEnroll();
var enroll = Assert.IsType<EnrollViewModel>(shell.Screen);
Assert.Equal(folder.File("admin.key"), enroll.KeyPath);
enroll.InvitationCode = invitation.Code.ToLowerInvariant();
await enroll.SubmitAsync("trop court", "trop court");
Assert.Contains("au moins", enroll.ErrorMessage);
await enroll.SubmitAsync(AdminTestKit.Passphrase, AdminTestKit.Passphrase + "!");
Assert.Contains("différentes", enroll.ErrorMessage);
Assert.False(File.Exists(enroll.KeyPath));
await enroll.SubmitAsync(AdminTestKit.Passphrase, AdminTestKit.Passphrase);
Assert.True(enroll.IsDone, enroll.ErrorMessage);
Assert.True(enroll.IsEnrolled);
Assert.Equal("Paul", enroll.AccountName);
Assert.Matches("^([0-9A-F]{4} ){4}[0-9A-F]{4}$", enroll.Fingerprint);
enroll.CopyFingerprintCommand.Execute(null);
Assert.Equal(enroll.Fingerprint, dialogs.Clipboard);
enroll.ContinueCommand.Execute(null);
var login = Assert.IsType<LoginViewModel>(shell.Screen);
Assert.Equal(enroll.CreatedKeyPath, login.SelectedKey!.Path);
Assert.Contains("approuvée", login.StatusMessage);
// Tant que la clé n'est pas approuvée, la connexion est refusée avec un message clair.
await login.LoginAsync(AdminTestKit.Passphrase);
Assert.False(shell.IsLoggedIn);
Assert.Contains("en attente d'approbation", login.ErrorMessage);
// Le propriétaire approuve avec l'empreinte affichée (le serveur ne la montre pas pour une clé en attente).
var paul = (await owner.ListAdminsAsync()).Single(a => a.DisplayName == "Paul");
Assert.Null(paul.Keys.Single().Fingerprint);
await owner.ApproveKeyAsync(paul.Id, paul.Keys.Single().KeyId, enroll.Fingerprint);
await login.LoginAsync(AdminTestKit.Passphrase);
Assert.True(shell.IsLoggedIn, login.ErrorMessage);
Assert.Equal("Paul", Assert.IsType<AdminWorkspaceViewModel>(shell.Screen).DisplayName);
}
[Fact]
public async Task Browsing_refuses_the_launcher_signing_key_and_plain_files()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
using var elsewhere = new TempFolder();
AdminTestKit.CreateKeyFile(elsewhere.Path, "launcher-signing").Signer.Dispose();
File.WriteAllText(elsewhere.File("texte.key"), "texte");
var (portable, signer) = AdminTestKit.CreateKeyFile(elsewhere.Path, "portable");
signer.Dispose();
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
var login = Assert.IsType<LoginViewModel>(shell.Screen);
dialogs.NextOpenFile = elsewhere.File("launcher-signing.key");
login.BrowseKeyCommand.Execute(null);
Assert.Contains("clé de signature du launcher", login.ErrorMessage);
dialogs.NextOpenFile = elsewhere.File("texte.key");
login.BrowseKeyCommand.Execute(null);
Assert.Contains("n'est pas une clé", login.ErrorMessage);
Assert.False(login.HasKeys);
dialogs.NextOpenFile = portable;
login.BrowseKeyCommand.Execute(null);
Assert.False(login.HasError);
Assert.Equal(portable, login.SelectedKey!.Path);
// Retrouvée à la prochaine ouverture de la fenêtre.
await using var again = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs));
Assert.Equal(portable, Assert.IsType<LoginViewModel>(again.Screen).SelectedKey!.Path);
}
private sealed class CountingPanel : AdminPanelViewModel
{
public CountingPanel(IAdminContext context) : base(context)
{
}
public int Activations { get; private set; }
protected override Task OnActivatedAsync(bool firstTime)
{
Activations++;
return Task.CompletedTask;
}
}
private sealed class ExtraPanels : ILauncherModule
{
public string Id => "extra";
public string DisplayName => "Pages de test";
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor("extra.admins", "Administrateurs", "", "AccountGroup",
AdminPermissions.AdminsManage, 10, c => new CountingPanel(c)));
registry.AddAdminPanel(new AdminPanelDescriptor("extra.notes", "Notes de version", "", "Info",
AdminPermissions.ChangelogEdit, 20, c => new CountingPanel(c)));
}
}
}
@@ -5,6 +5,8 @@
• Contracts : messages signés, clés, empreintes, codes d'invitation, rôles, textes.
• Server : la Launcher API est démarrée en mémoire (port local aléatoire, dossier temporaire)
et attaquée comme le ferait un client réel ou malveillant.
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
écrans) contre ce même serveur, sans interface graphique.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -29,6 +31,7 @@
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
<ProjectReference Include="..\..\server\GameLauncher.Server\GameLauncher.Server.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
</ItemGroup>
<ItemGroup>