Compare commits

...
Author SHA1 Message Date
nicolas 92ae5cbe63 Version 2.0.0 2026-11-01 21:37:38 +01:00
nicolas 86593a43ee Montée en version 2.0.0 2026-11-01 21:26:15 +01:00
nicolas 8a7902e5e8 Version 2.0.0 2026-11-01 21:04:06 +01:00
nicolas 50ef1025d8 Changelog version 2.0.0 2026-11-01 20:54:52 +01:00
nicolas bcae86f1a1 Documentation : phase 7 (signature de bout en bout)
README.md, docs/ADMIN-ARCHITECTURE.md, server/deploy/LISEZMOI.md
2026-11-01 20:54:38 +01:00
nicolas bcde2a0dac Tests : signature du contenu, trousseau, page Signature, module serveur
tests/GameLauncher.Tests/Signing/SigningKit.cs, ContentSigningTests.cs, ContentTrustTests.cs (nouveaux)
Server/SigningModuleTests.cs, Admin/SigningPanelTests.cs (nouveaux)
Admin/AdminServicesTests.cs, Admin/AdminTestKit.cs
2026-11-01 20:54:11 +01:00
nicolas 86fef875a6 Outils : sign-keyring (signature hors ligne du trousseau, comparaison au précédent)
tools/LauncherPublisher/Program.cs, tools/sign-keyring.bat (nouveau), GameLauncher.sln
2026-11-01 20:53:13 +01:00
nicolas 1b397ec930 Launcher : configuration contentSigning/keyringMinSequence, blocage par sécurité, vues de la page Signature
src/GameLauncher/App.xaml.cs, Platform/LauncherUpdateService.cs, Services/AdminWindowService.cs, Themes/Admin.xaml, ViewModels/GameViewModel.cs, launcher.json
2026-11-01 20:52:45 +01:00
nicolas c89bd62aff Launcher (joueurs) : vérification du trousseau, du manifest et du catalogue (contentSigning)
src/GameLauncher.Core/Trust/ContentTrustService.cs (nouveau)
Models/GameManifest.cs, Models/LauncherCatalog.cs, Services/ManifestClient.cs, Catalog/CatalogService.cs
2026-11-01 20:51:37 +01:00
nicolas f556848da4 Menu d’administration : page Signature, signature à la publication, clé racine refusée à la connexion
src/GameLauncher.ApiClient/AdminSession.cs
src/GameLauncher.Modules.Admin/Signing/SigningAdminModule.cs, SigningPanelViewModel.cs (nouveaux)
Publishing/ReleaseUploader.cs, Publishing/PublishPanelViewModel.cs, Catalog/GamesPanelViewModel.cs, Accounts/AccountItems.cs, Services/AdminLoginService.cs, ViewModels/LoginViewModel.cs, ViewModels/AdminEnvironment.cs, ViewModels/AdminWorkspaceViewModel.cs
src/GameLauncher.Modules/Admin/IAdminContext.cs
2026-11-01 20:50:31 +01:00
nicolas 1cf9e87e82 Serveur : publication du trousseau, vérification des signatures du manifest et du catalogue
Nouveaux : server/GameLauncher.Server/Modules/Signing/SigningModule.cs, Security/ContentSignatures.cs, Storage/KeyringStore.cs, Storage/ManifestSignatures.cs, Storage/PublicFileWriter.cs
Modifiés : Hosting/IApiModule.cs, Hosting/LauncherApiOptions.cs, Storage/CatalogStore.cs, Storage/PublicationStore.cs, Modules/Catalog/CatalogModule.cs, Modules/Publishing/PublishingModule.cs, Modules/Admins/AdminsModule.cs, appsettings.json
server/deploy/nginx-catalog.conf
2026-11-01 20:48:51 +01:00
nicolas dc09c3ecef Contrats : trousseau des clés et signature du contenu (phase 7)
src/GameLauncher.Contracts/Signing/ContentSigning.cs, Keyring.cs, SigningDtos.cs (nouveaux)
Security/SignedMessages.cs, ApiRoutes.cs, Catalog/CatalogDocument.cs, Dtos/AdminDtos.cs
2026-11-01 20:46:19 +01:00
nicolas cbcc0b4f44 Documentation : phase 6 (administrateurs et journal d'audit) 2026-11-01 14:45:25 +01:00
nicolas d0db02d7f3 Tests : pages Mes appareils, Administrateurs et Journal d'audit
tests/GameLauncher.Tests/Admin/AccountsPanelTests.cs (nouveau, 4 tests)
tests/GameLauncher.Tests/Admin/AuditPanelTests.cs (nouveau, 2 tests)
2026-11-01 14:45:03 +01:00
nicolas 63c2e37dad Launcher : vues des pages Mes appareils, Administrateurs et Journal d'audit, enregistrement des modules
src/GameLauncher/Themes/Admin.xaml : modèles des trois pages, plus les liens du tableau de bord.
src/GameLauncher/Themes/Icons.xaml : icônes Laptop et AccountRemove.
src/GameLauncher/App.xaml.cs : AccountsAdminModule et AuditAdminModule.
2026-11-01 14:44:43 +01:00
nicolas 78780c20f1 Tableau de bord : liens vers « Administrateurs » et « Mes appareils » 2026-11-01 14:44:16 +01:00
nicolas 3b95317d4c Administration : page « Journal d'audit » (filtres, pages, export CSV) 2026-11-01 14:44:01 +01:00
nicolas bb165c4be0 Administration : pages « Mes appareils » et « Administrateurs » (ViewModels)
src/GameLauncher.Modules.Admin/Accounts/AccountItems.cs (nouveau) : base commune, appareils, invitations, code affiché une seule fois.
src/GameLauncher.Modules.Admin/Accounts/AdminsPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/MyDevicesPanelViewModel.cs (nouveau)
src/GameLauncher.Modules.Admin/Accounts/AccountsAdminModule.cs (nouveau)
2026-11-01 14:43:38 +01:00
nicolas d2cb48b901 Serveur : route « mon compte », suppression de ses propres invitations « nouvel appareil », audit détaillé
server/GameLauncher.Server/Modules/Admins/AdminsModule.cs
tests/GameLauncher.Tests/Server/AdminManagementTests.cs : nouveau test (son compte, ses invitations, droits de suppression, journal).
2026-11-01 14:43:01 +01:00
nicolas e06ddacf04 Contrats et client : compte de l'administrateur connecté (GET v1/admins/me), auteur des invitations
src/GameLauncher.Contracts/ApiRoutes.cs : route MyAccount.
src/GameLauncher.Contracts/Dtos/AdminDtos.cs : MyAccount, et InvitationSummary.CreatedById.
src/GameLauncher.ApiClient/AdminSession.cs : GetMyAccountAsync
2026-11-01 14:39:59 +01:00
nicolas 2e4b779be5 Déploiement : dossiers objects/00 à ff créés sur le VPS avec le bit setgid 2026-11-01 13:36:14 +01:00
nicolas 10558a6e5f Documentation : création des dossiers objects sous systemd 2026-11-01 12:52:36 +01:00
nicolas 54941a1009 Tests : droits des dossiers objects, test du bandeau d'envoi interrompu stabilisé
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : nouveau test sur les droits des dossiers objects/ et objects/xx.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : test du bandeau rendu stable (le refus 429 tombe sur un petit envoi).
tests/GameLauncher.Tests/Server/ChangelogModuleTests.cs : le faux dossier du jeu ne redemande pas le setgid s’il l’a déjà.
2026-11-01 12:52:25 +01:00
nicolas bd8b76015f Serveur : dossiers objects créés sans demander le bit setgid (RestrictSUIDSGID de systemd) 2026-11-01 12:51:55 +01:00
nicolas 0d3d76a68e Déploiement : zone nginx publication_par_ip, guide et architecture
server/deploy/nginx-publications.conf
server/deploy/LISEZMOI.md
docs/ADMIN-ARCHITECTURE.md
2026-11-01 12:27:08 +01:00
nicolas 8862047e36 Tests : limite des envois, délais d'attente, bandeau d'envoi interrompu:
tests/GameLauncher.Tests/Infrastructure/TestApi.cs : valeur par défaut de la nouvelle limite.
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : deux tests.
Le premier vérifie qu’un envoi n’est pas freiné par la limite générale, et que les refus indiquent le délai d’attente.
Le second vérifie le calcul des attentes.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : un test vérifie que le bandeau n’apparaît pas pendant l’envoi, que la pause est affichée, qu’on peut interrompre pendant la pause puis abandonner.
2026-11-01 12:26:52 +01:00
nicolas 25c87d672e Publication : bandeau « interrompu » masqué pendant l'envoi 2026-11-01 12:25:58 +01:00
nicolas d687414872 Publication : pauses courtes après un refus (Retry-After), pause affichée sur la page 2026-11-01 12:25:42 +01:00
nicolas fb71661f17 Serveur : limite de requêtes propre aux envois de publication, Retry-After sur les refus 429 2026-11-01 12:25:04 +01:00
nicolas 19ccd3bf9a Version 1.8 du jeu 2026-11-01 11:59:29 +01:00
nicolas 370443f3d4 Commandes : notification des boutons renvoyée sur le fil de l'interface, tests 2026-11-01 11:59:13 +01:00
nicolas 213cb9a533 Publication : avancement et ouverture rappelés sur le fil de l'interface 2026-11-01 11:58:39 +01:00
nicolas dc740e0452 Documentation : phase 5 (publication d'une version) 2026-10-31 20:17:22 +01:00
nicolas 72e979afd6 Déploiement : routes d'envoi dans nginx, guide du VPS 2026-10-31 20:17:02 +01:00
nicolas 1d5f85a77c Launcher : page « Publier une version » (vues, icônes) et enregistrement du module 2026-10-31 20:16:38 +01:00
nicolas 629fb600e9 Administration : module « Publication » (contrôle du dossier, envoi avec reprise, opération longue) 2026-10-31 20:16:04 +01:00
nicolas 9455277c25 Client API : requêtes d'envoi signées, délai par requête 2026-10-31 20:14:47 +01:00
nicolas f1032df74a Serveur : module Publishing (envoi par morceaux, reprise, mise en ligne, audit) 2026-10-31 20:14:27 +01:00
nicolas a341bc641a Serveur : fichiers publics des jeux communs aux notes de version et à la publication 2026-10-31 20:13:55 +01:00
nicolas 9b544cbc03 Contrats : publication d'une version (routes, échanges, contrôle des manifests) 2026-10-31 20:12:46 +01:00
nicolas 9aa3876a0e Changelog 1.3.0 2026-10-25 12:11:21 +01:00
88 changed files with 10451 additions and 413 deletions

No files matched your search

+2
View File
@@ -30,6 +30,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
tools\admin-key.bat = tools\admin-key.bat
tools\sign-keyring.bat = tools\sign-keyring.bat
tools\publish-server.bat = tools\publish-server.bat
server-example\web.config = server-example\web.config
EndProjectSection
@@ -45,6 +46,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
server\deploy\launcher-api.service = server\deploy\launcher-api.service
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.conf
server\deploy\nginx-publications.conf = server\deploy\nginx-publications.conf
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
+43 -5
View File
@@ -35,10 +35,10 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
| `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, catalogue et notes de version (modèle et limites) (voir §8) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publié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 ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux ») et « Notes de version » (`Changelog/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `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 |
@@ -50,7 +50,7 @@ src/GameLauncher/
├─ 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), Admin.xaml (menu d'administration,
│ dont les pages « Jeux » et « Notes de version »)
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
├─ 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),
@@ -86,6 +86,11 @@ suivante. Pas de manifest en ligne (première publication) : historique local. S
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
**Mise en ligne** : depuis le menu d'administration, page **Publier une version** (§8) : choisissez le
`manifest.json` du dossier `--output`, puis « Publier ». Seuls les fichiers absents du serveur sont
envoyés, l'envoi reprend après une coupure, et le manifest est mis en ligne en dernier. WinSCP reste
possible (objects/ et l'archive d'abord, `manifest.json` en dernier), mais pas les deux en même temps.
### Format de `notes.txt`
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
@@ -483,7 +488,9 @@ Chantier en cours, découpé en phases : architecture, sécurité et planning da
**`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).
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**Notes de version**. Les autres pages de gestion arrivent dans les phases suivantes.
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
servi aux joueurs (page **Signature**, voir plus bas).
* **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é,
@@ -522,6 +529,36 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
`server/deploy/LISEZMOI.md`, étape 3 ter.
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
si même numéro ou version inférieure), puis envoi : manifest, fichiers **absents du serveur**
seulement (petits fichiers par lots, gros par morceaux), archive par morceaux de 4 Mo, chaque fichier
vérifié (SHA-256) des deux côtés, et mise en ligne avec le manifest **en dernier** (historique des
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
étape 3 quater.
* **Page « Mes appareils »** (tout administrateur) : vos PC (clés), avec leur empreinte et leur dernière
connexion. « Ajouter un appareil » crée une invitation « nouvel appareil » (code affiché une seule
fois) ; sur le nouveau PC, « J'ai une invitation » ; de retour ici, l'appareil en attente s'approuve
en saisissant l'empreinte affichée sur le nouveau PC. Personne d'autre ne peut approuver un appareil
à votre nom. Révoquer un appareil perdu : propriétaires (sinon, demandez à un propriétaire).
* **Page « Administrateurs »** (permission `admins.manage` : propriétaires) : inviter (nom, rôle → code
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
qu'il vous dicte, changer un rôle, révoquer un appareil ou un compte, supprimer une invitation.
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
(`keyring.json`), signé **hors ligne** par la clé racine du launcher avec `tools\sign-keyring.bat` et publié
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
de 200). Filtres par catégorie, résultat et texte (nom, IP, clé…), export CSV des entrées filtrées.
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
@@ -541,5 +578,6 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|---|---|
| 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é et se connecter : aussi depuis le launcher |
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
+159 -25
View File
@@ -268,7 +268,7 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|---|:-:|:-:|:-:|---|
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
| `admins.manage` | ✔ | | | Gestion des administrateurs (page « Administrateurs ») |
| `audit.read` | ✔ | ✔ | | Journal d'audit |
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
@@ -288,8 +288,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.)
(Étapes 3 et 4 : « J'ai une invitation » ; étapes 1 et 5 : page « Administrateurs » du launcher,
phase 6. `tools\admin-key.bat` reste possible en ligne de commande.)
**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**.
@@ -420,32 +420,166 @@ publication n'est pas configurée.
version vient de `notes.txt`. 404 = première publication (historique local) ; toute autre erreur
arrête la publication. Republier une version sans `--notes` garde ses notes existantes.
## 11. Publication d'une version (phase 5)
## 11. Publication d'une version (phase 5 ✅)
Depuis le menu admin › « Publier une version » : choix du dossier produit par le `.bat`, contrôle
local (manifest valide, archive et objets présents avec les bonnes empreintes), puis envoi :
Depuis le menu admin › « Publier une version » (module `PublishAdminModule`, permission
`releases.publish`) : choix du `manifest.json` produit par le `.bat`, contrôle local (manifest valide,
archive et objets présents avec la bonne taille), puis envoi :
1. `objects/` : seuls les objets **absents du serveur** (le serveur répond à une liste d'empreintes) ;
2. l'archive ZIP, **par morceaux** (8 Mo, reprise possible, chaque morceau et le fichier final
vérifiés par SHA-256) ;
3. `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
1. le manifest (par morceaux), contrôlé par le serveur ; il répond par la liste des objets **absents**
de son dossier `objects/` ;
2. ces objets seulement : petits fichiers **par lots** (une requête pour des centaines de fichiers),
gros fichiers par morceaux ;
3. l'archive ZIP **par morceaux** de 4 Mo (reprise à la position reçue) ;
4. la mise en ligne : `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
Le serveur vérifie que chaque fichier référencé existe avec la bonne taille et la bonne empreinte
avant d'accepter le manifest. L'historique des notes du serveur est conservé.
**Contrôles communs** (`Contracts/Publishing/ManifestCheck`, launcher et serveur) : identifiant de jeu
et version attendus ; archive = simple nom `.zip` à côté du manifest ; objets dans `objects/` uniquement ;
chemins relatifs sûrs, sans doublon ; exécutable présent ; 10 000 fichiers, 128 Go d'archive au plus.
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
**Envoi** (`Storage/PublicationStore`, module `Publishing`) : toutes les requêtes sont **signées** (y
compris chaque morceau : la signature couvre l'empreinte du corps). Seules les routes d'envoi acceptent
8 Mo (limite propre à la route, côté Kestrel et nginx) ; le reste de l'API garde 64 Ko. Dépôt dans un
dossier **caché** du dossier du jeu (`.publish-<id>`, 0700, refusé par nginx) : la mise en ligne ne
consiste qu'en renommages sur le même disque. Chaque morceau doit suivre exactement ce qui a été reçu
(409 `offset_mismatch` sinon) ; l'empreinte SHA-256 est calculée au fil des morceaux (recalculée si le
service a redémarré) et vérifiée avant d'accepter le fichier. Espace disque vérifié (507). Une
publication par jeu ; celle d'un autre administrateur n'est remplaçable qu'après 30 min sans envoi ;
abandon au bout de 48 h. Limite de requêtes : les routes d'envoi de morceaux ont leur propre limite
(`Limits:PublicationUploadsPerMinute`, 1500/min par IP, politique `publication-upload`, à la place de la
limite générale) ; côté nginx, zone `publication_par_ip` (25/s). Chaque refus 429 du serveur annonce
son délai (`Retry-After`).
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
distribuer un faux jeu. La phase 7 ajoute :
**Mise en ligne** : tout est revérifié, puis contrôles **avant tout déplacement** (liens symboliques,
droits, dossiers `objects/xx`) ; objets, archive (l'ancienne, de même nom, gardée de côté et remise en
place si l'écriture du manifest échoue), puis manifest. Refus si les fichiers en ligne ont changé
pendant l'envoi (empreinte du manifest **hors notes** : une correction de notes reste permise).
Notes : celles de la version publiée, puis l'historique **du serveur** (corrections en ligne
conservées), dans les bornes du launcher. Résultat gardé 24 h : une réponse perdue n'empêche pas de
constater la mise en ligne. Audit : `release.start`, `release.publish`, `release.cancel`.
Dossiers `objects/xx` manquants : créés **sans demander de bit spécial** (systemd refuse au service toute
demande de setuid/setgid, `RestrictSUIDSGID=yes`) ; le groupe `www-data` vient du bit setgid de `objects/`,
et les droits sont ceux du dossier du jeu, mais sans setgid (le fixer sous le masque du service le retire) :
sans conséquence pour le launcher, qui y déplace des fichiers déjà du bon groupe, mais un fichier déposé
avec WinSCP y prendrait le groupe `deploy`. D'où l'étape 3 quater : `objects/` et ses 256 sous-dossiers
créés sur le VPS en `2750`.
* `keyring.json` : liste des clés publiques des administrateurs, **signée par la clé racine**
(votre clé de signature du launcher, déjà intégrée à l'exe) ;
* une **signature détachée** (`manifest.json.sig`, `catalog.json.sig`) produite par la clé de
l'administrateur au moment de la publication ou de l'édition — les anciens launchers ignorent
ces fichiers ;
* côté joueur, vérification puis refus de tout contenu non signé par une clé du trousseau.
**Launcher** (`Modules.Admin/Publishing/ReleaseUploader`) : les empreintes des fichiers sont vérifiées
pendant leur lecture (un fichier modifié depuis le `.bat` est refusé) ; coupures (réseau, délai,
429, serveur occupé, position décalée) : l'état est relu sur le serveur et l'envoi continue, après une
pause affichée sur la page (429 : délai `Retry-After` du serveur, sinon 1 s doublée à chaque refus,
30 s au plus) ; « Interrompre »
garde ce qui a été reçu, « Publier » reprend. La mise en ligne elle-même ne s'interrompt pas. Pendant
l'envoi, `IAdminContext.BeginLongOperation()` évite le verrouillage pour inactivité (verrouillage manuel,
de Windows et durée maximale de session toujours appliqués).
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
**VPS** : bloc nginx `server/deploy/nginx-publications.conf`, écriture dans le dossier du jeu
(`games.conf`, étape 3 ter), dossier `objects` existant avec le bit setgid (`LISEZMOI.md`, étape 3 quater).
## 11 bis. Administrateurs et journal d'audit (phase 6 ✅)
Module `AccountsAdminModule` (pages « Mes appareils » et « Administrateurs ») et module
`AuditAdminModule` (page « Journal d'audit »), sur les routes de la phase 1. La page ne propose que ce
que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste seul juge).
* **Mes appareils** (tout administrateur connecté) : nouvelle route `GET v1/admins/me` (son compte, ses
clés, ses invitations « nouvel appareil » en cours ; empreinte masquée pour une clé en attente, comme
dans la liste complète). Ajout d'un PC : invitation `ForAdminId` = soi, puis approbation depuis ce PC
avec l'empreinte lue sur le nouveau. Révoquer un de ses appareils reste réservé à `admins.manage`.
Un administrateur peut supprimer une invitation « nouvel appareil » **créée par lui pour lui**
(`DELETE v1/admins/invitations/{id}` ouverte à tout administrateur, droit revérifié dans la
modification) ; les autres exigent `admins.manage`. `InvitationSummary.CreatedById` ajouté.
* **Administrateurs** (`admins.manage`) : comptes en attente d'abord, puis actifs (vous en premier), les
révoqués sur demande ; par compte : rôle (liste + « Appliquer », confirmation renforcée pour
« Propriétaire »), révocation ; par appareil : approbation (empreinte saisie : format contrôlé localement,
comparaison par le serveur en temps constant), révocation (sauf la clé de la session en cours). Nouvel
appareil d'un autre compte actif : la page explique qu'il l'approuve lui-même ; compte sans appareil
actif : restauration sur le VPS (`add-key`).
* **Code d'invitation** : affiché une seule fois (copie dans le presse-papiers, « J'ai transmis le code »
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
texte ; limite des 5 000 entrées signalée) ; export CSV des entrées filtrées, toutes les cellules entre
guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
tant qu'un code non transmis est affiché.
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
**Ancre de confiance** : la ou les clés racine intégrées à l'exe (`Security\update-signing.pub`, celles des
mises à jour du launcher). La clé privée racine reste **hors ligne** : le menu d'administration refuse toujours
de l'ouvrir (nom de fichier réservé, et toute clé dont l'identifiant ou la clé publique est celle d'une racine
intégrée, quel que soit le nom du fichier) ; elle ne sert qu'à `LauncherPublisher` (mises à jour du launcher,
`sign-keyring`).
**Trousseau** (`keyring.json`, `Contracts/Signing/Keyring`) : `sequence` croissante, `issuedAt`, clés
(identifiant, clé publique SPKI, nom, autorisations `releases` et/ou `catalog`), signature racine sur le SHA-256
du trousseau canonique (message `GameLauncher/Keyring/v1`, clés et autorisations triées). Production : page
**Signature** (propriétaires) › « Exporter la demande » (appareils ACTIFS des comptes actifs ; `releases` si le rôle
a `releases.publish`, `catalog` s'il a `games.edit` ; noms débarrassés des caractères invisibles, de mise en forme
ou d'inversion du sens d'écriture, refusés aussi par `Keyring.Validate`) → `tools\sign-keyring.bat` (compare la
demande au trousseau signé précédent `keyring-N.json` du même dossier, vérifié avec `update-signing.pub`, et
signale les **nouvelles clés** et autorisations élargies ; la demande vient du serveur et n'est donc pas digne de
confiance : ces empreintes se vérifient hors du serveur, avec celle que la page **Mes appareils** calcule sur le
PC de chacun — pas celle annoncée par le serveur ; confirmation, phrase de passe ; refuse une racine absente
d'`update-signing.pub` ; écrit `keyring-N.json`) → « Publier le trousseau signé » (le launcher vérifie la racine et
le numéro avant l'envoi ; l'export reste possible quand le trousseau publié est refusé par ce launcher). Les noms
sont publics. Serveur : `PUT v1/keyring` (`admins.manage`,
requête signée) contrôle la structure et un numéro strictement croissant, écrit le fichier public (par défaut à
côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish` (clés ajoutées/retirées). La liste
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
permettrait de calculer l'empreinte à dicter).
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
chacun à partir de ce qu'il a lu (même code dans `Contracts`) :
* manifest : jeu, version, exécutable, arguments, archive (adresse, taille, SHA-256), base des objets, fichiers
(chemin, taille, SHA-256) triés — **pas** les notes ni la date : les notes restent corrigeables en ligne (texte
borné, jamais exécuté) ; le launcher des joueurs calcule le même condensé depuis son modèle `GameManifest` ;
* catalogue : tout sauf `revision`, `updatedAt` et `signature` (posés par le serveur), jeux triés par identifiant
**tel que publié** (sans mise en minuscules) ; un catalogue dont deux jeux ne diffèrent que par la casse est
refusé par les launchers (`HasDuplicateGames`), même signé.
Les membres absents ou `null` (trousseau, manifest) ne provoquent jamais d'exception : trousseau refusé, condensé
calculé sur des valeurs vides.
**Production des signatures** (clé de la session ; date = heure du serveur) : à la mise en ligne d'une version
(`ReleaseUploader`, juste avant le commit : la reprise des envois, fondée sur l'empreinte du manifest, n'est pas
touchée ; corps `PublicationCommitRequest`), à chaque publication du catalogue (page Jeux, sur le catalogue
contrôlé tel qu'il sera publié), et pour une version déjà en ligne (WinSCP, antérieure) : page « Publier une
version » › « Signer la version en ligne » depuis son dossier (`POST v1/games/{id}/signature`, accepté seulement si
le condensé en ligne est celui du dossier). Le serveur vérifie chaque signature (clé = celle de la session, date à
± 10 min de son heure — une date future bloquerait les mises à jour suivantes chez les joueurs, signature valide)
et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire toute `signature` d'un manifest reçu
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
sécurité », pas d'installation ni de mise à jour, le jeu installé reste jouable), catalogue ignoré (cache conservé).
Le cache du catalogue contient le catalogue tel que publié (signature comprise) : il est revérifié à chaque
démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou modifié sur le disque est ignoré en
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
pas les versions suivantes).
**Limites assumées** : images du catalogue non signées ; un launcher qui n'a jamais vu de trousseau accepte tout
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
(la page Signature le signale : re-signer d'abord).
## 13. Phases
@@ -457,9 +591,9 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
| **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 » | ✅ |
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
| **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 |
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
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.
@@ -27,7 +27,9 @@ public static class ApiModules
new Modules.Admins.AdminsModule(),
new Modules.Audit.AuditModule(),
new Modules.Catalog.CatalogModule(),
new Modules.Changelog.ChangelogModule()
new Modules.Changelog.ChangelogModule(),
new Modules.Publishing.PublishingModule(),
new Modules.Signing.SigningModule()
};
}
@@ -36,4 +38,10 @@ public static class RateLimitPolicies
{
public const string Auth = "auth";
public const string Enrollment = "enrollment";
/// <summary>
/// Morceaux envoyés pendant une publication (manifest, objets, archive) : limite propre, plus haute,
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
/// </summary>
public const string PublicationUpload = "publication-upload";
}
@@ -41,6 +41,10 @@ public sealed class LauncherApiOptions
public CatalogSettings Catalog { get; set; } = new();
public KeyringSettings Keyring { get; set; } = new();
public ContentSigningSettings ContentSigning { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
@@ -76,6 +80,32 @@ public sealed class LauncherApiOptions
public string PublicUrl { get; set; } = "";
}
/// <summary>
/// Trousseau (phase 7) : clés autorisées à signer le contenu, signé par la clé racine HORS LIGNE puis publié
/// depuis la page « Signature » du menu d'administration.
/// </summary>
public sealed class KeyringSettings
{
/// <summary>
/// Fichier public (chemin absolu .json). Vide : « keyring.json » dans le dossier du catalogue (déjà autorisé
/// en écriture et servi par nginx), si le catalogue est configuré ; sinon publication désactivée.
/// </summary>
public string PublicFile { get; set; } = "";
/// <summary>Adresse publique (information, « keyringUrl » de launcher.json). Vide : à côté du catalogue.</summary>
public string PublicUrl { get; set; } = "";
}
/// <summary>Signature du contenu servi aux joueurs (phase 7).</summary>
public sealed class ContentSigningSettings
{
/// <summary>
/// Vrai : catalogue et versions ne sont publiés que signés (refus sinon). À activer une fois le trousseau
/// publié et les launchers en mode « require » : évite de mettre en ligne un contenu que ces launchers refuseraient.
/// </summary>
public bool Require { get; set; }
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -95,6 +125,13 @@ public sealed class LauncherApiOptions
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
public int AuthPerMinute { get; set; } = 20;
/// <summary>
/// Morceaux envoyés pendant une publication, par minute et par adresse IP (à la place de
/// <see cref="RequestsPerMinute"/> pour ces routes). 1500 morceaux de 4 Mo ≈ 100 Mo/s. Ces requêtes
/// exigent une session et le droit de publier : le corps n'est lu qu'après ces contrôles.
/// </summary>
public int PublicationUploadsPerMinute { get; set; } = 1500;
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
public int EnrollmentsPerHour { get; set; } = 10;
@@ -125,6 +162,12 @@ public sealed class LauncherApiOptions
/// </summary>
public string CatalogImageHost { get; private set; } = "";
/// <summary>Fichier public du trousseau en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
public string? KeyringPublicFileFullPath { get; private set; }
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
public string KeyringPublicUrl { get; private set; } = "";
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -156,6 +199,7 @@ public sealed class LauncherApiOptions
TrustedProxyAddresses = proxies;
ValidateCatalog();
ValidateKeyring();
ValidateGames();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
@@ -164,7 +208,8 @@ public sealed class LauncherApiOptions
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1, "Limits");
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1 &&
Limits.PublicationUploadsPerMinute >= 1, "Limits");
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
}
@@ -201,6 +246,44 @@ public sealed class LauncherApiOptions
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
}
private void ValidateKeyring()
{
KeyringPublicFileFullPath = null;
if (!string.IsNullOrWhiteSpace(Keyring.PublicFile))
{
var file = Keyring.PublicFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être un chemin absolu vers un fichier .json.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, "Keyring:PublicFile");
KeyringPublicFileFullPath = full;
}
else if (CatalogPublicFileFullPath is { } catalog)
{
KeyringPublicFileFullPath = Path.Combine(Path.GetDirectoryName(catalog)!, "keyring.json");
}
if (KeyringPublicFileFullPath is { } keyring && CatalogPublicFileFullPath is { } catalogFile &&
string.Equals(keyring, catalogFile, StringComparison.Ordinal))
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être différent du fichier du catalogue.");
if (string.IsNullOrWhiteSpace(Keyring.PublicUrl))
{
KeyringPublicUrl = new Uri(new Uri(CatalogPublicUrl), "keyring.json").AbsoluteUri;
}
else if (Uri.TryCreate(Keyring.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
{
KeyringPublicUrl = url.AbsoluteUri;
}
else
{
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
}
}
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Threading.RateLimiting;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
@@ -5,6 +6,7 @@ using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Hosting;
@@ -64,10 +66,20 @@ public static class ServerApp
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = (context, _) => new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
limiter.OnRejected = (context, _) =>
{
// Délai avant la prochaine fenêtre : le launcher reprend juste à temps au lieu d'attendre à l'aveugle.
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
};
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
IsPublicationUpload(context)
? RateLimitPartition.GetNoLimiter("publication") // limite propre (politique PublicationUpload)
: RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.PublicationUpload, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.PublicationUploadsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
@@ -112,6 +124,10 @@ public static class ServerApp
return app;
}
/// <summary>Route d'envoi de morceaux d'une publication (politique <see cref="RateLimitPolicies.PublicationUpload"/>).</summary>
private static bool IsPublicationUpload(HttpContext context) =>
context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == RateLimitPolicies.PublicationUpload;
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
{
PermitLimit = permits,
@@ -33,10 +33,12 @@ public sealed class AdminsModule : IApiModule
manage.MapGet(ApiRoutes.Admins, List);
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
// Ouvertes à tout administrateur connecté : chacun peut voir son compte, ajouter et approuver SES
// appareils, et supprimer les invitations « nouvel appareil » qu'il a créées pour lui-même ;
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
api.MapGet(ApiRoutes.MyAccount, MyAccount).RequireAdmin();
api.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
@@ -49,6 +51,24 @@ public sealed class AdminsModule : IApiModule
private static IResult List(AdminStore store) =>
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
/// <summary>
/// Compte de l'administrateur connecté : ses clés (empreinte masquée pour celles en attente, comme dans
/// la liste complète : il la lit sur le nouvel appareil) et ses invitations « nouvel appareil » en cours.
/// </summary>
private static IResult MyAccount(HttpContext context, AdminStore store, TimeProvider time)
{
var actor = context.Admin();
var now = time.GetUtcNow();
var db = store.Read();
var admin = db.FindAdmin(actor.Admin.Id) ?? throw new ApiRejection(403, ApiErrorCodes.Forbidden, "Compte introuvable.");
return Results.Json(new MyAccount
{
Admin = ToSummary(admin),
Invitations = db.Invitations.Where(i => i.ForAdminId == admin.Id && i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
.Select(ToSummary).ToList()
}, ApiJson.Options);
}
private static IResult ListInvitations(AdminStore store, TimeProvider time)
{
var now = time.GetUtcNow();
@@ -107,20 +127,24 @@ public sealed class AdminsModule : IApiModule
return record;
});
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}", InvitationDetails(invitation));
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
}
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
{
var actor = context.Admin();
store.Update(db =>
var deleted = store.Update(db =>
{
EnsureActor(db, actor);
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
// Sans admins.manage : seulement une invitation « nouvel appareil » créée par soi-même, pour soi-même.
var invitation = db.Invitations.FirstOrDefault(i => i.Id == invitationId);
var own = invitation is not null && invitation.ForAdminId == actor.Admin.Id && invitation.CreatedById == actor.Admin.Id;
EnsureActor(db, actor, own ? AdminPermission.None : AdminPermission.Manage);
if (invitation is null) throw ApiRejection.NotFound("Invitation introuvable.");
db.Invitations.Remove(invitation);
return invitation;
});
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}", InvitationDetails(deleted));
return Results.NoContent();
}
@@ -285,6 +309,9 @@ public sealed class AdminsModule : IApiModule
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
}
private static string InvitationDetails(InvitationRecord invitation) =>
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}";
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
string result = AuditResults.Ok)
{
@@ -306,6 +333,7 @@ public sealed class AdminsModule : IApiModule
Status = k.Status,
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
PublicKey = k.Status == AdminStatus.Active ? Convert.ToBase64String(k.PublicKeyBytes()) : null,
AddedAt = k.AddedAt,
LastUsedAt = k.LastUsedAt
}).ToList()
@@ -319,6 +347,7 @@ public sealed class AdminsModule : IApiModule
ForAdminId = invitation.ForAdminId,
CreatedAt = invitation.CreatedAt,
CreatedBy = invitation.CreatedBy,
CreatedById = invitation.CreatedById,
ExpiresAt = invitation.ExpiresAt
};
}
@@ -3,6 +3,7 @@ using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
@@ -50,6 +51,20 @@ public sealed class CatalogModule : IApiModule
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
// Signature (phase 7) : sur le catalogue tel qu'il sera publié (après contrôle), par la clé de cette session.
var actor = context.Admin();
if (request.Catalog.Signature is { } signature)
{
ContentSignatures.RequireFromActor(signature, ContentKinds.Catalog, ContentKinds.CatalogId,
ContentSigning.CatalogDigest(catalog), actor, time.GetUtcNow());
catalog = catalog with { Signature = signature };
}
else if (options.Value.ContentSigning.Require)
{
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
"Le serveur exige un catalogue signé (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
}
CatalogDocument published, previous;
try
{
@@ -62,9 +77,9 @@ public sealed class CatalogModule : IApiModule
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
var actor = context.Admin();
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", Describe(previous, published));
$"révision {published.Revision}",
Describe(previous, published) + (published.Signature is null ? " ; non signé" : $" ; signé (clé {published.Signature.KeyId})"));
return Results.Json(State(published, store, options.Value), ApiJson.Options);
}
@@ -0,0 +1,151 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Publishing;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5) : permission releases.publish,
/// TOUTES les requêtes signées (y compris chaque morceau de fichier), audit du début, de la mise en
/// ligne et de l'abandon. Les routes d'envoi acceptent des corps jusqu'à 8 Mo (les autres : 64 Ko).
/// </summary>
public sealed class PublishingModule : IApiModule
{
public string Name => "Publishing";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<PublicationStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
const string permission = AdminPermissions.ReleasesPublish;
api.MapPost(ApiRoutes.PublicationStartPattern, Start).RequirePermission(permission).RequireSignedRequest();
api.MapGet(ApiRoutes.PublicationPattern, GetState).RequirePermission(permission);
api.MapDelete(ApiRoutes.PublicationPattern, Cancel).RequirePermission(permission).RequireSignedRequest();
api.MapPut(ApiRoutes.PublicationManifestPattern, Manifest).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationArchivePattern, Archive).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationObjectPattern, UploadObject).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationObjectsPattern, UploadObjects).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationCommitPattern, Commit).RequirePermission(permission).RequireSignedRequest();
}
private static IResult Start(string gameId, PublicationStartRequest request, HttpContext context, PublicationStore store, AuditLog audit,
ILogger<PublishingModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
var actor = context.Admin();
return Guard(logger, () =>
{
var (state, replaced) = store.Start(gameId, request, actor.Admin.Id, actor.Admin.DisplayName);
if (replaced is not null)
Audit(audit, context, "release.cancel", replaced.GameId, replaced.Version, $"remplacée par la version {state.Version}");
if (!state.Resumed)
Audit(audit, context, "release.start", state.GameId, state.Version, $"version en ligne : {Display(state.PublishedVersion)}");
return Results.Json(state, ApiJson.Options);
});
}
private static IResult GetState(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.GetState(id, context.Admin().Admin.Id), ApiJson.Options));
private static IResult Cancel(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
var record = store.Cancel(id, context.Admin().Admin.Id);
Audit(audit, context, "release.cancel", record.GameId, record.Version, "abandonnée par l'administrateur");
return Results.NoContent();
});
private static IResult Manifest(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendManifest(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult Archive(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendArchive(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult UploadObject(string id, string sha256, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger)
{
if (!PublishingLimits.IsValidSha256(sha256)) return ApiResults.BadRequest("Empreinte d'objet invalide.");
return Guard(logger, () =>
Results.Json(store.AppendObject(id, context.Admin().Admin.Id, sha256, offset, context.CapturedBody()), ApiJson.Options));
}
private static IResult UploadObjects(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AddObjects(id, context.Admin().Admin.Id, context.CapturedBody()), ApiJson.Options));
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
// Corps facultatif : signature du contenu du manifest (phase 7). Sans corps : mise en ligne non signée.
PublicationCommitRequest? request = null;
var body = context.CapturedBody();
if (body.Length > 0)
{
try
{
request = JsonSerializer.Deserialize<PublicationCommitRequest>(body, ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Requête de mise en ligne illisible.");
}
}
var signature = request?.Signature;
if (signature is null && options.Value.ContentSigning.Require)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
"Le serveur exige une version signée (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
var actor = context.Admin();
var (result, record) = store.Commit(id, actor.Admin.Id, signature,
(gameId, digest) => ContentSignatures.RequireFromActor(signature!, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()));
if (record is null) return Results.Json(result, ApiJson.Options); // déjà en ligne (réponse précédente perdue)
Audit(audit, context, "release.publish", result.GameId, result.Version,
$"version précédente : {Display(result.PreviousVersion)} ; {result.ObjectsAdded} fichier(s) ajouté(s)" +
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}" +
(signature is null ? " ; non signée" : $" ; signée (clé {signature.KeyId})"));
return Results.Json(result, ApiJson.Options);
});
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin (le détail va dans journalctl).</summary>
private static IResult Guard(ILogger logger, Func<IResult> action)
{
try
{
return action();
}
catch (ManifestWriteRefusedException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Publication refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
}
catch (InvalidDataException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Publication d'une version");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire dans le dossier du jeu : vérifiez ses droits et l'espace disque (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static void Audit(AuditLog audit, HttpContext context, string action, string gameId, string version, string details)
{
var actor = context.Admin();
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{gameId} {version}", details);
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,191 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Signing;
/// <summary>
/// Signature du contenu servi aux joueurs (phase 7) :
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
/// </summary>
public sealed class SigningModule : IApiModule
{
public string Name => "Signing";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<KeyringStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Signing, State).RequirePermission(AdminPermissions.AdminsManage);
api.MapPut(ApiRoutes.Keyring, PublishKeyring).RequirePermission(AdminPermissions.AdminsManage).RequireSignedRequest();
api.MapGet(ApiRoutes.ManifestSignaturePattern, GetManifest).RequirePermission(AdminPermissions.ReleasesPublish);
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
}
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
ILogger<SigningModule> logger)
{
var settings = options.Value;
var db = admins.Read();
var manifests = new List<ManifestSignatureState>();
foreach (var (gameId, path) in settings.GameManifestFiles.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
{
try
{
manifests.Add(ManifestSignatures.Read(gameId, path, db));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or
FormatException or InvalidOperationException)
{
// Manifest illisible (fichier modifié à la main…) : affiché « non signé », la page reste utilisable.
logger.LogWarning(ex, "Signature du manifest de {GameId}", gameId);
manifests.Add(new ManifestSignatureState { GameId = gameId });
}
}
CatalogSignatureState? catalog = null;
if (catalogs.PublishingEnabled)
{
CatalogDocument? published;
try
{
published = catalogs.ReadPublished();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature du catalogue");
published = null;
}
catalog = published is null
? new CatalogSignatureState()
: new CatalogSignatureState
{
Revision = published.Revision,
Digest = ContentSigning.CatalogDigest(published),
Signature = published.Signature,
SignerName = ContentSignatures.SignerName(db, published.Signature)
};
}
KeyringDocument? keyring;
try
{
keyring = keyrings.Read();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
// Trousseau de référence illisible : la page propose d'en publier un nouveau (numéro choisi par le serveur au contrôle).
logger.LogWarning(ex, "Trousseau de référence illisible");
keyring = null;
}
return Results.Json(new SigningState
{
Keyring = keyring,
KeyringPublishingEnabled = keyrings.PublishingEnabled,
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
RequireSignatures = settings.ContentSigning.Require,
Manifests = manifests,
Catalog = catalog
}, ApiJson.Options);
}
private static IResult PublishKeyring(HttpContext context, KeyringStore keyrings, AuditLog audit, ILogger<SigningModule> logger)
{
KeyringDocument? keyring;
try
{
keyring = Keyring.Parse(context.CapturedBody());
}
catch (JsonException ex)
{
return ApiResults.BadRequest($"Trousseau illisible : {ex.Message}");
}
if (keyring is null) return ApiResults.BadRequest("Trousseau absent.");
KeyringDocument? previous;
try
{
previous = keyrings.Publish(keyring);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Publication du trousseau");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le trousseau public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
catch (Exception ex) when (ex is InvalidDataException or JsonException)
{
// Trousseau de référence du serveur illisible : refus (le numéro du trousseau publié ne peut pas être contrôlé).
logger.LogError(ex, "Trousseau de référence illisible");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le trousseau de référence du serveur est illisible : restaurez-le depuis l'historique (keyring-history/) avant de publier.");
}
var actor = context.Admin();
var added = keyring.Keys.Select(k => k.KeyId).Except(previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).ToList();
var removed = (previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).Except(keyring.Keys.Select(k => k.KeyId)).ToList();
audit.Write("keyring.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"trousseau n° {keyring.Sequence}",
$"{keyring.Keys.Count} clé(s), racine {keyring.Signature?.RootKeyId}" +
(added.Count > 0 ? $" ; ajoutées : {string.Join(", ", added)}" : "") +
(removed.Count > 0 ? $" ; retirées : {string.Join(", ", removed)}" : ""));
return Results.Json(keyring, ApiJson.Options);
}
private static IResult GetManifest(string gameId, AdminStore admins, IOptions<LauncherApiOptions> options)
{
var path = ManifestPath(gameId, options.Value);
return Results.Json(ManifestSignatures.Read(gameId, path, admins.Read()), ApiJson.Options);
}
private static IResult SignManifest(string gameId, ManifestSignRequest request, HttpContext context, AdminStore admins,
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
{
var path = ManifestPath(gameId, options.Value);
if (request.Signature is null || !PublishingSha(request.Digest)) return ApiResults.BadRequest("Signature ou condensé absent.");
var actor = context.Admin();
ManifestSignatureState state;
try
{
state = ManifestSignatures.Sign(gameId, path, options.Value.DataDirectoryFullPath, request,
digest => ContentSignatures.RequireFromActor(request.Signature, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()),
admins.Read());
}
catch (ManifestWriteRefusedException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Signature refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Signature du manifest de {GameId}", gameId);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu réécrire le manifest : vérifiez les droits du dossier du jeu (server/deploy/LISEZMOI.md, étape 3 ter).");
}
audit.Write("release.sign", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{gameId} {state.Version}", $"version en ligne signée (contenu {state.Digest[..12]})");
return Results.Json(state, ApiJson.Options);
}
private static bool PublishingSha(string? value) => Contracts.Publishing.PublishingLimits.IsValidSha256(value);
private static string ManifestPath(string gameId, LauncherApiOptions options)
{
if (!CatalogRules.IsValidGameId(gameId)) throw ApiRejection.BadRequest("Identifiant de jeu invalide.");
return options.GameManifestFiles.TryGetValue(gameId, out var path)
? path
: throw new ApiRejection(404, ApiErrorCodes.NotConfigured,
"Jeu non configuré sur le serveur (LauncherApi:Games, voir server/deploy/LISEZMOI.md).");
}
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Security;
/// <summary>
/// Contrôle, par le serveur, des signatures de contenu envoyées par le menu d'administration (phase 7). Le
/// serveur n'est pas l'ancre de confiance (ce sont les launchers des joueurs, avec le trousseau) : ce contrôle
/// évite seulement de mettre en ligne une signature que les launchers refuseraient, et garantit que la clé qui
/// signe est celle de la session (rien n'est signé au nom d'un autre).
/// </summary>
public static class ContentSignatures
{
/// <exception cref="ApiRejection">400 : signature d'une autre clé, date hors tolérance, ou signature invalide.</exception>
public static void RequireFromActor(ContentSignature signature, string kind, string id, string digest,
AuthenticatedAdmin actor, DateTimeOffset now)
{
if (!string.Equals(signature.KeyId, actor.Key.KeyId, StringComparison.Ordinal))
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid, "La signature du contenu doit être faite par la clé de cette session.");
if (signature.SignedAt < now - ContentSigning.MaxSignedAtSkew || signature.SignedAt > now + ContentSigning.MaxSignedAtSkew)
throw new ApiRejection(400, ApiErrorCodes.ClockSkew,
"Date de signature du contenu trop éloignée de l'heure du serveur : vérifiez l'heure de Windows, puis recommencez.");
if (!ContentSigning.VerifyWithKey(kind, id, digest, signature, actor.Key.PublicKeyBytes()))
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid,
"Signature du contenu invalide : le contenu signé ne correspond pas à celui envoyé.");
}
/// <summary>« Nicolas (PC fixe) » pour une clé connue du serveur, sinon null.</summary>
public static string? SignerName(AdminDatabase db, ContentSignature? signature)
{
if (signature is null || db.FindKey(signature.KeyId) is not { } found) return null;
return string.IsNullOrWhiteSpace(found.Key.Label) ? found.Admin.DisplayName : $"{found.Admin.DisplayName} ({found.Key.Label})";
}
/// <summary>Signature présente dans un manifest publié (null : absente ou illisible).</summary>
public static ContentSignature? ReadSignature(JsonObject manifest)
{
if (PublishedFiles.Property(manifest, "signature") is not JsonObject node) return null;
try
{
return node.Deserialize<ContentSignature>(ApiJson.Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -24,15 +24,43 @@ public static class SignedRequests
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
!HttpMethods.IsOptions(context.Request.Method))
{
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
var body = buffer.ToArray();
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
feature.MaxRequestBodySize = limit.MaxBytes;
byte[] body;
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
{
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
body = new byte[length];
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
throw new BadHttpRequestException("Corps plus long que annoncé.");
}
else
{
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
body = buffer.ToArray();
}
context.Items[BodyKey] = body;
context.Request.Body = new MemoryStream(body, writable: false);
}
await next();
});
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
public static byte[] CapturedBody(this HttpContext context) =>
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
builder.WithMetadata(new RequestBodyLimit(maxBytes));
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.AddEndpointFilter(async (invocation, next) =>
@@ -94,3 +122,6 @@ public static class SignedRequests
return null;
}
}
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
public sealed record RequestBodyLimit(long MaxBytes);
@@ -23,8 +23,6 @@ public sealed class CatalogStore
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private const UnixFileMode PublicFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
private readonly string _path;
private readonly string _lockPath;
@@ -57,6 +55,14 @@ public sealed class CatalogStore
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
}
/// <summary>Catalogue publié tel qu'écrit (sans normalisation : signature comprise), ou null s'il n'a jamais été publié.</summary>
public CatalogDocument? ReadPublished()
{
if (!File.Exists(_path)) return null;
return JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(_path), Json)
?? throw new InvalidDataException($"{FileName} est vide.");
}
/// <summary>
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
/// fichier public, puis référence privée et historique.
@@ -110,37 +116,7 @@ public sealed class CatalogStore
}
}
private void WritePublic(string json)
{
var directory = Path.GetDirectoryName(_publicFile!)!;
var temp = Path.Combine(directory, $".{Path.GetFileName(_publicFile)}.{Guid.NewGuid():N}.tmp");
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PublicFileMode;
using (var stream = new FileStream(temp, options))
{
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json));
stream.Flush(flushToDisk: true);
}
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PublicFileMode);
File.Move(temp, _publicFile!, overwrite: true); // renommage atomique dans le même dossier
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique)
}
throw new IOException($"Écriture du catalogue public impossible ({_publicFile}) : {ex.Message}", ex);
}
}
private void WritePublic(string json) => PublicFileWriter.Write(_publicFile!, json, "catalogue public");
private void RestorePrivate(string? previousText)
{
@@ -1,9 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
@@ -24,29 +20,7 @@ namespace GameLauncher.Server.Storage;
public sealed class ChangelogStore
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = 64L * 1024 * 1024;
private const string HistoryFolder = "manifest-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
private static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
MaxDepth = 64
};
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
public const long MaxManifestBytes = PublishedFiles.MaxManifestBytes;
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
@@ -81,7 +55,7 @@ public sealed class ChangelogStore
{
var (path, key) = Locate(gameId);
lock (_writeLock)
using (SecureFiles.AcquireLock(Path.Combine(_dataDirectory, $"manifest-{key.ToLowerInvariant()}.lock"), LockTimeout))
using (PublishedFiles.LockGame(_dataDirectory, key))
{
var bytes = ReadManifest(path);
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
@@ -92,7 +66,7 @@ public sealed class ChangelogStore
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
var index = -1;
for (var i = 0; i < changelog.Count; i++)
if (changelog[i] is JsonObject candidate && string.Equals(StringValue(candidate, "version"), version, StringComparison.Ordinal))
if (changelog[i] is JsonObject candidate && string.Equals(PublishedFiles.StringValue(candidate, "version"), version, StringComparison.Ordinal))
{
index = i;
break;
@@ -111,13 +85,8 @@ public sealed class ChangelogStore
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
var replacement = old.DeepClone().AsObject();
var edited = JsonSerializer.SerializeToNode(clean, ManifestJson)!.AsObject();
foreach (var name in EditableFields)
{
foreach (var existing in replacement.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
replacement.Remove(existing);
if (edited[name] is { } value) replacement[name] = value.DeepClone();
}
var edited = JsonSerializer.SerializeToNode(clean, PublishedFiles.ManifestJson)!.AsObject();
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.DeepClone());
changelog[index] = replacement;
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
@@ -126,12 +95,9 @@ public sealed class ChangelogStore
throw ApiRejection.BadRequest(
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
var json = root.ToJsonString(ManifestJson);
var newBytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json);
if (newBytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
Backup(key, bytes);
WriteManifest(path, newBytes, baseSha256);
var newBytes = PublishedFiles.Serialize(root);
PublishedFiles.Backup(_dataDirectory, key, bytes);
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
return (State(key, root, newBytes), previous);
}
}
@@ -143,33 +109,18 @@ public sealed class ChangelogStore
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Notes de version non modifiables pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
throw PublishedFiles.NotConfigured(gameId, "Modification des notes de version");
}
private static byte[] ReadManifest(string path)
{
var info = new FileInfo(path);
if (!info.Exists) throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
return File.ReadAllBytes(path);
}
private static byte[] ReadManifest(string path) =>
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
private static JsonObject Parse(byte[] bytes, string gameId)
internal static JsonObject Parse(byte[] bytes, string gameId)
{
JsonObject root;
try
{
root = JsonNode.Parse(StripBom(bytes), documentOptions: ReadOptions) as JsonObject
?? throw new InvalidDataException("le manifest n'est pas un objet JSON");
}
catch (Exception ex) when (ex is JsonException or ArgumentException)
{
throw new InvalidDataException($"Manifest illisible : {ex.Message}", ex);
}
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
var manifestGameId = StringValue(root, "gameId");
var manifestGameId = PublishedFiles.StringValue(root, "gameId");
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
@@ -184,18 +135,18 @@ public sealed class ChangelogStore
return new ChangelogState
{
GameId = gameId,
ManifestVersion = StringValue(root, "version") ?? "",
ManifestVersion = PublishedFiles.StringValue(root, "version") ?? "",
ManifestSha256 = Sha256(bytes),
Entries = entries
};
}
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
private static List<ChangelogEntry?> Entries(JsonArray? changelog)
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
{
try
{
return changelog?.Deserialize<List<ChangelogEntry?>>(ManifestJson) ?? new List<ChangelogEntry?>();
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.ManifestJson) ?? new List<ChangelogEntry?>();
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
@@ -204,107 +155,11 @@ public sealed class ChangelogStore
}
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
private static JsonArray? ChangelogArray(JsonObject root)
{
foreach (var (name, value) in root)
if (string.Equals(name, "changelog", StringComparison.OrdinalIgnoreCase))
return value as JsonArray;
return null;
}
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
private static string? StringValue(JsonObject node, string name)
{
foreach (var (key, value) in node)
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
return value is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
return null;
}
private static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
private static ApiRejection Conflict() => ApiRejection.Conflict(
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
ApiErrorCodes.RevisionConflict);
// ------------------------------------------------------------------ Écriture
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération).</summary>
private void Backup(string gameId, byte[] bytes)
{
try
{
var directory = Path.Combine(_dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
SecureFiles.EnsureDirectory(Path.Combine(_dataDirectory, HistoryFolder));
SecureFiles.EnsureDirectory(directory);
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// La copie est une aide : son échec n'empêche pas la modification.
}
}
/// <summary>
/// Remplace le manifest publié : fichier temporaire caché (refusé par nginx) dans le même dossier,
/// droits du manifest existant, empreinte revérifiée, puis renommage atomique.
/// </summary>
private static void WriteManifest(string path, byte[] bytes, string baseSha256)
{
var directory = Path.GetDirectoryName(path)!;
if (new FileInfo(path).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
if (!OperatingSystem.IsWindows())
{
// Le fichier remplaçant appartient au compte du service, avec le groupe du dossier SEULEMENT si
// celui-ci a le bit setgid : sinon nginx (groupe www-data) pourrait perdre l'accès au manifest.
var mode = File.GetUnixFileMode(path);
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) &&
!File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
throw new ManifestWriteRefusedException(
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : le manifest remplacé ne serait plus lisible par nginx.");
}
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
using (var stream = new FileStream(temp, new FileStreamOptions
{
Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None
}))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, File.GetUnixFileMode(path));
// Dernier contrôle : un envoi (WinSCP…) pendant la préparation n'est pas écrasé.
if (!string.Equals(Sha256(File.ReadAllBytes(path)), baseSha256, StringComparison.OrdinalIgnoreCase))
throw Conflict();
File.Move(temp, path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique, caché)
}
if (ex is ApiRejection) throw;
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
}
}
}
/// <summary>Remplacement du manifest refusé par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
public sealed class ManifestWriteRefusedException : IOException
{
public ManifestWriteRefusedException(string message) : base(message) { }
}
@@ -0,0 +1,98 @@
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Trousseau publié (phase 7).
/// • Référence : keyring.json dans le dossier des données privées (+ historique).
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Keyring:PublicFile, par défaut à côté
/// du catalogue), écrite atomiquement, lisible par tous.
/// • Le serveur ne détient pas la clé racine et ne vérifie que la structure et le numéro (toujours croissant) :
/// c'est le launcher qui vérifie la signature racine (avant l'envoi dans le menu d'administration, puis chez
/// chaque joueur). Un trousseau falsifié serait refusé par tous les launchers.
/// </summary>
public sealed class KeyringStore
{
public const string FileName = "keyring.json";
private const string HistoryFolder = "keyring-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _writeLock = new();
public KeyringStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.KeyringPublicFileFullPath) { }
public KeyringStore(string dataDirectory, string? publicFile)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
}
public bool PublishingEnabled => _publicFile is not null;
/// <summary>Trousseau publié, ou null.</summary>
public KeyringDocument? Read()
{
if (!File.Exists(_path)) return null;
return Keyring.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
/// <summary>Publie un trousseau signé (structure contrôlée, numéro supérieur au trousseau actuel).</summary>
/// <returns>Le trousseau précédent (null : premier).</returns>
public KeyringDocument? Publish(KeyringDocument keyring)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou LauncherApi:Catalog:PublicFile).");
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors)) throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
if (keyring.Signature is null || string.IsNullOrEmpty(keyring.Signature.Value))
throw ApiRejection.BadRequest("Trousseau non signé : signez-le d'abord avec la clé racine (tools\\sign-keyring.bat).");
lock (_writeLock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var current = Read();
if (current is not null && keyring.Sequence <= current.Sequence)
throw ApiRejection.Conflict(
$"Trousseau n° {keyring.Sequence} refusé : le trousseau publié porte déjà le n° {current.Sequence} " +
"(exportez une nouvelle demande, qui prend le numéro suivant).", ApiErrorCodes.RevisionConflict);
var json = Keyring.Serialize(keyring);
if (System.Text.Encoding.UTF8.GetByteCount(json) > Keyring.MaxBytes)
throw ApiRejection.BadRequest($"Trousseau trop volumineux (au plus {Keyring.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte le numéro) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "trousseau public");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(keyring.Sequence, json);
return current;
}
}
private void WriteHistory(long sequence, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"keyring-{sequence:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "keyring-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,68 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Signature du manifest déjà en ligne d'un jeu (version publiée avant la phase 7, ou avec WinSCP) : le launcher
/// envoie la signature du contenu de SON manifest.json (dossier de publication) ; le serveur ne l'ajoute que si la
/// version en ligne a exactement ce contenu, puis réécrit le manifest atomiquement (mêmes règles que les notes).
/// </summary>
public static class ManifestSignatures
{
public static ManifestSignatureState Read(string gameId, string manifestPath, AdminDatabase admins)
{
if (PublishedFiles.ReadManifest(manifestPath) is not { } bytes) return new ManifestSignatureState { GameId = gameId };
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
var signature = ContentSignatures.ReadSignature(root);
return new ManifestSignatureState
{
GameId = gameId,
Version = PublishedFiles.StringValue(root, "version") ?? "",
Digest = Digest(bytes),
Signature = signature,
SignerName = ContentSignatures.SignerName(admins, signature)
};
}
/// <param name="verify">Contrôle de la signature sur le condensé de la version en ligne (lève une ApiRejection).</param>
public static ManifestSignatureState Sign(string gameId, string manifestPath, string dataDirectory, ManifestSignRequest request,
Action<string> verify, AdminDatabase admins)
{
using (PublishedFiles.LockGame(dataDirectory, gameId))
{
var bytes = PublishedFiles.ReadManifest(manifestPath)
?? throw ApiRejection.NotFound("Aucune version en ligne pour ce jeu.");
var digest = Digest(bytes);
if (!string.Equals(digest, request.Digest, StringComparison.OrdinalIgnoreCase))
throw ApiRejection.Conflict(
"La version en ligne ne correspond pas au manifest.json choisi (fichiers, archive ou exécutable différents) : " +
"choisissez le dossier de publication de la version en ligne.", ApiErrorCodes.RevisionConflict);
verify(digest);
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
PublishedFiles.SetProperty(root, "signature", JsonSerializer.SerializeToNode(request.Signature, ApiJson.Options));
var newBytes = PublishedFiles.Serialize(root);
PublishedFiles.Backup(dataDirectory, gameId, bytes);
PublishedFiles.WriteManifest(manifestPath, newBytes, PublishedFiles.Sha256(bytes),
() => ApiRejection.Conflict("Le manifest a été modifié pendant la signature : recommencez.", ApiErrorCodes.RevisionConflict));
return Read(gameId, manifestPath, admins);
}
}
private static string Digest(byte[] bytes)
{
try
{
return ContentSigning.ManifestDigest(PublishedFiles.StripBom(bytes));
}
catch (JsonException ex)
{
throw new InvalidDataException($"Manifest en ligne illisible : {ex.Message}", ex);
}
}
}
@@ -0,0 +1,47 @@
using System.Text;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichier PUBLIC servi par nginx (catalogue, trousseau) : écrit atomiquement (fichier temporaire caché du
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
/// </summary>
internal static class PublicFileWriter
{
public const UnixFileMode Mode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
public static void Write(string path, string text, string what)
{
var directory = Path.GetDirectoryName(path)!;
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
using (var stream = new FileStream(temp, options))
{
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text));
stream.Flush(flushToDisk: true);
}
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, Mode);
File.Move(temp, path, overwrite: true); // renommage atomique dans le même dossier
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique)
}
throw new IOException($"Écriture du {what} impossible ({path}) : {ex.Message}", ex);
}
}
}
@@ -0,0 +1,850 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5).
/// • Dépôt : dossier CACHÉ « .publish-&lt;id&gt; » dans le dossier du jeu (même disque : la mise en ligne
/// finale n'est qu'une suite de renommages ; nginx refuse les chemins commençant par un point), droits
/// 0700 ; description (version, plan) dans les données privées.
/// • Envoi : manifest, puis objets absents du serveur (par lots, ou par morceaux pour les gros), puis
/// archive par morceaux ; chaque fichier est reçu à la suite (position vérifiée : reprise possible
/// après une coupure) puis vérifié (SHA-256) avant d'être accepté.
/// • Mise en ligne (commit) : tout est revérifié AVANT de déplacer quoi que ce soit ; objets puis archive
/// installés, manifest en DERNIER (comme avec WinSCP), historique des notes du serveur conservé ; si
/// l'écriture du manifest échoue, l'archive remplacée est remise en place. Jamais si les fichiers en
/// ligne ont changé pendant l'envoi (autre publication, WinSCP).
/// • Une publication par jeu ; abandonnée au bout de 48 h sans envoi ; résultat conservé 24 h (une
/// réponse perdue n'empêche pas de constater que la version est en ligne).
/// </summary>
public sealed class PublicationStore
{
private const string RecordsFolder = "publications";
private const string StagingPrefix = ".publish-";
private const string DoneSuffix = ".done";
private const long FreeSpaceMargin = 256L * 1024 * 1024;
private static readonly TimeSpan DoneLifetime = TimeSpan.FromHours(24);
private static readonly TimeSpan CleanUpEvery = TimeSpan.FromMinutes(10);
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly string _recordsDirectory;
private readonly TimeProvider _time;
private readonly object _startLock = new();
private readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, HashProgress> _hashes = new(StringComparer.Ordinal);
private DateTimeOffset _lastCleanUp = DateTimeOffset.MinValue;
public PublicationStore(IOptions<LauncherApiOptions> options, TimeProvider time)
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath, time) { }
public PublicationStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory, TimeProvider time)
{
_manifests = manifests;
_dataDirectory = dataDirectory;
_recordsDirectory = Path.Combine(dataDirectory, RecordsFolder);
_time = time;
}
// ================================================================== Début, état, abandon
/// <summary>
/// Début d'une publication, ou reprise si le même manifest était en cours d'envoi. Une publication
/// d'un autre administrateur, active depuis moins de 30 min, n'est pas remplacée (409).
/// </summary>
/// <returns>L'état, et la publication remplacée (audit) le cas échéant.</returns>
public (PublicationState State, PublicationRecord? Replaced) Start(string gameId, PublicationStartRequest request, Guid adminId, string adminName)
{
var (manifestPath, key) = Locate(gameId);
if (!ManifestCheck.IsValidVersion(request.Version)) throw ApiRejection.BadRequest("Numéro de version invalide.");
if (!PublishingLimits.IsValidSha256(request.ManifestSha256)) throw ApiRejection.BadRequest("Empreinte du manifest invalide.");
if (request.ManifestSize is <= 0 or > PublishingLimits.MaxManifestBytes) throw ApiRejection.BadRequest("Taille du manifest invalide.");
lock (_startLock)
{
CleanUp(force: true);
PublicationRecord? replaced = null;
foreach (var existing in Records().Where(r => string.Equals(r.GameId, key, StringComparison.OrdinalIgnoreCase)).ToList())
{
if (existing.AdminId == adminId && existing.Version == request.Version &&
string.Equals(existing.ManifestSha256, request.ManifestSha256, StringComparison.OrdinalIgnoreCase))
{
Touch(existing.Id);
return (Lock(existing.Id, () => State(Current(existing.Id), resumed: true)), null);
}
var idle = _time.GetUtcNow() - LastActivity(existing.Id);
if (existing.AdminId != adminId && idle < PublishingLimits.TakeoverAfter)
throw ApiRejection.Conflict(
$"{existing.AdminName} publie la version {existing.Version} de ce jeu (dernier envoi il y a " +
$"{Math.Max(1, (int)idle.TotalMinutes)} min). Réessayez plus tard.", ApiErrorCodes.PublicationInProgress);
Lock(existing.Id, () =>
{
Delete(existing);
return 0;
});
replaced = existing;
}
var current = PublishedFiles.ReadManifest(manifestPath);
var currentRoot = current is null ? null : ChangelogStore.Parse(current, key);
var record = new PublicationRecord
{
Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
GameId = key,
Version = request.Version,
ManifestSha256 = request.ManifestSha256.ToLowerInvariant(),
ManifestSize = request.ManifestSize,
AdminId = adminId,
AdminName = adminName,
CreatedAt = _time.GetUtcNow(),
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
PublishedContentSha256 = OnlineContentHash(currentRoot)
};
CreateStaging(record);
Save(record);
return (Lock(record.Id, () => State(record, resumed: false)), replaced);
}
}
public PublicationState GetState(string id, Guid adminId)
{
Find(id, adminId);
return Lock(id, () =>
{
EnsurePlan(Current(id));
return State(Current(id), resumed: false);
});
}
/// <summary>Abandon : dépôt supprimé, rien n'est publié.</summary>
public PublicationRecord Cancel(string id, Guid adminId)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
Delete(record);
return record;
});
}
// ================================================================== Envoi
public ChunkResult AppendManifest(string id, Guid adminId, long offset, byte[] data)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
var staging = Staging(record);
var result = Append(Path.Combine(staging, "manifest.part"), Path.Combine(staging, "manifest.json"), offset, data,
record.ManifestSize, record.ManifestSha256, "manifest");
Touch(id);
if (result.Complete) EnsurePlan(record);
return result;
});
}
public ChunkResult AppendArchive(string id, Guid adminId, long offset, byte[] data)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
if (!plan.ArchiveNeeded) return new ChunkResult { Received = plan.ArchiveSize, Complete = true };
var staging = Staging(record);
var result = Append(Path.Combine(staging, "archive.part"), Path.Combine(staging, "archive.zip"), offset, data,
plan.ArchiveSize, plan.ArchiveSha256, "archive");
Touch(id);
return result;
});
}
/// <summary>Gros objet, par morceaux.</summary>
public ChunkResult AppendObject(string id, Guid adminId, string sha256, long offset, byte[] data)
{
Find(id, adminId);
var sha = sha256.ToLowerInvariant();
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
if (!plan.Pending.TryGetValue(sha, out var size))
throw ApiRejection.BadRequest("Cet objet n'est pas attendu par cette publication.");
var objects = StagingObjects(record);
var result = Append(Path.Combine(objects, sha + ".part"), Path.Combine(objects, sha + ".bin"), offset, data, size, sha, "objet");
Touch(id);
return result;
});
}
/// <summary>Lot de petits objets (chacun vérifié) ; renvoie le nombre d'objets enregistrés.</summary>
public ChunkResult AddObjects(string id, Guid adminId, byte[] body)
{
Find(id, adminId);
List<(string Sha256, ReadOnlyMemory<byte> Data)> batch;
try
{
batch = ObjectBatch.Read(body, PublishingLimits.MaxObjectsPerBatch);
}
catch (InvalidDataException ex)
{
throw ApiRejection.BadRequest(ex.Message);
}
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
var objects = StagingObjects(record);
var saved = 0;
foreach (var (sha, data) in batch)
{
if (!plan.Pending.TryGetValue(sha, out var size))
throw ApiRejection.BadRequest("Un objet du lot n'est pas attendu par cette publication.");
if (data.Length != size) throw ApiRejection.BadRequest($"Objet {sha[..12]}… : taille différente de celle du manifest.");
if (PublishedFiles.Sha256(data.Span) != sha)
throw ApiRejection.BadRequest($"Objet {sha[..12]}… : contenu différent de son empreinte (fichier modifié ou abîmé).");
var done = Path.Combine(objects, sha + ".bin");
if (!File.Exists(done))
{
var temp = Path.Combine(objects, $"{sha}.{Guid.NewGuid():N}.tmp");
PublishedFiles.WriteNew(temp, data.Span, PrivateFile);
File.Move(temp, done, overwrite: true);
}
saved++;
}
Touch(id);
return new ChunkResult { Received = saved, Complete = true };
});
}
// ================================================================== Mise en ligne
/// <summary>
/// Met la version en ligne : objets, archive, puis manifest (historique des notes du serveur conservé).
/// Déjà faite (réponse précédente perdue) : le même résultat est renvoyé.
/// </summary>
/// <returns>Le résultat, et l'enregistrement (null si la mise en ligne avait déjà eu lieu).</returns>
/// <param name="signature">Signature du contenu du manifest par l'administrateur (phase 7), ou null.</param>
/// <param name="verifySignature">Contrôle de la signature sur le condensé du manifest reçu (lève une ApiRejection).</param>
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId, ContentSignature? signature = null,
Action<string, string>? verifySignature = null)
{
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
if (Load(id) is null && LoadDone(id) is { } done && done.AdminId == adminId) return (done.Result, null);
Find(id, adminId);
return Lock(id, () =>
{
if (Load(id) is null && LoadDone(id) is { } finished && finished.AdminId == adminId) return (finished.Result, (PublicationRecord?)null);
var record = Current(id);
var (manifestPath, gameId) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var plan = RequirePlan(record);
var state = State(record, resumed: false);
if (state.PendingObjects.Count > 0 || !state.ArchiveComplete)
throw ApiRejection.Conflict(
$"Envoi incomplet : {state.PendingObjects.Count} fichier(s) et {(state.ArchiveComplete ? "aucune archive" : "l'archive")} restent à envoyer.");
var staging = Staging(record);
var manifestBytes = File.ReadAllBytes(Path.Combine(staging, "manifest.json"));
var errors = new List<string>();
var summary = ManifestCheck.Check(manifestBytes, gameId, record.Version, errors)
?? throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
using (PublishedFiles.LockGame(_dataDirectory, gameId))
{
var current = PublishedFiles.ReadManifest(manifestPath);
var currentRoot = current is null ? null : ChangelogStore.Parse(current, gameId);
var currentVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "";
// Fichiers en ligne changés pendant l'envoi (autre publication, WinSCP) : jamais écrasés. Une
// correction des notes (page Notes de version) ne change que « changelog » : elle est permise.
if (OnlineContentHash(currentRoot) != record.PublishedContentSha256)
throw ApiRejection.Conflict(
$"La version en ligne a changé pendant l'envoi ({Display(record.PublishedVersion)} → {Display(currentVersion)}) : " +
"vérifiez avant de publier la vôtre, puis recommencez.", ApiErrorCodes.RevisionConflict);
// Signature (phase 7) : sur le contenu reçu (fichiers, archive, exécutable…), que la fusion des notes ne touche pas.
if (signature is not null)
verifySignature?.Invoke(gameId, ContentSigning.ManifestDigest(PublishedFiles.StripBom(manifestBytes)));
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne) et la
// signature de cette mise en ligne (une signature éventuellement présente dans le fichier reçu est retirée).
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
PublishedFiles.SetProperty(root, "signature",
signature is null ? null : JsonSerializer.SerializeToNode(signature, Contracts.ApiJson.Options));
var newBytes = PublishedFiles.Serialize(root);
// 0. Contrôles AVANT tout déplacement : droits, liens symboliques, dossiers objects/xx.
if (new FileInfo(manifestPath).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
var archiveTarget = Path.Combine(gameDirectory, summary.ArchiveName);
if (new FileInfo(archiveTarget).LinkTarget is not null)
throw new ManifestWriteRefusedException("l'archive publiée est un lien symbolique : la remplacer romprait le lien.");
var mode = PublishedFiles.PublicMode(gameDirectory, current is null ? null : manifestPath);
var objectsDirectory = Path.Combine(gameDirectory, "objects");
PublishedFiles.EnsurePublicDirectory(objectsDirectory, gameDirectory, keepSetGid: true);
foreach (var prefix in plan.Pending.Keys.Select(sha => sha[..2]).Distinct())
PublishedFiles.EnsurePublicDirectory(Path.Combine(objectsDirectory, prefix), gameDirectory, keepSetGid: false);
// 1. Objets (contenu adressé par l'empreinte : un objet présent est le bon s'il a la bonne taille).
var added = 0;
foreach (var (sha, size) in plan.Pending)
{
var staged = Path.Combine(StagingObjects(record), sha + ".bin");
var target = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha));
if (ObjectPresent(target, size))
{
PublishedFiles.TryDelete(staged);
continue;
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, mode);
File.Move(staged, target, overwrite: true);
added++;
}
foreach (var (sha, size) in summary.Objects)
if (!ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
throw ApiRejection.Conflict(
$"Le fichier {ManifestCheck.ObjectPath(sha)} a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
// 2. Archive : l'ancienne (même nom : republication) est gardée de côté jusqu'à l'écriture du manifest.
var stagedArchive = Path.Combine(staging, "archive.zip");
string? previousArchive = null;
var archiveAdded = false;
if (File.Exists(stagedArchive))
{
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(stagedArchive, mode);
if (File.Exists(archiveTarget))
{
previousArchive = PublishedFiles.TempPath(archiveTarget);
File.Move(archiveTarget, previousArchive);
}
File.Move(stagedArchive, archiveTarget);
archiveAdded = true;
}
else if (!File.Exists(archiveTarget) || new FileInfo(archiveTarget).Length != summary.ArchiveSize)
{
throw ApiRejection.Conflict("L'archive a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
}
// 3. Manifest en dernier : les launchers ne voient jamais une version incomplète.
try
{
if (current is not null) PublishedFiles.Backup(_dataDirectory, gameId, current);
PublishedFiles.WriteManifest(manifestPath, newBytes, current is null ? null : PublishedFiles.Sha256(current),
() => ApiRejection.Conflict("Le manifest a été modifié pendant la mise en ligne : recommencez.", ApiErrorCodes.RevisionConflict));
}
catch
{
// Archive remise dans le dépôt (pas besoin de la renvoyer), l'ancienne remise en ligne.
if (archiveAdded)
{
File.Move(archiveTarget, stagedArchive, overwrite: true);
if (previousArchive is not null) File.Move(previousArchive, archiveTarget);
}
throw;
}
if (previousArchive is not null) PublishedFiles.TryDelete(previousArchive);
var result = new PublicationResult
{
GameId = gameId,
Version = record.Version,
PreviousVersion = currentVersion,
ManifestSha256 = PublishedFiles.Sha256(newBytes),
ObjectsAdded = added,
ArchiveAdded = archiveAdded
};
SaveDone(new PublicationDone { Id = id, AdminId = record.AdminId, Result = result, At = _time.GetUtcNow() });
Delete(record);
return (result, (PublicationRecord?)record);
}
});
}
/// <summary>
/// Notes : celles de la version publiée (du manifest reçu ; à défaut, celles déjà en ligne), puis
/// l'historique du SERVEUR (qui contient les corrections faites en ligne), dans les bornes du launcher
/// (30 versions ; les plus anciennes retirées si l'ensemble est trop long).
/// Sans manifest en ligne : les notes du manifest reçu.
/// </summary>
private static JsonArray? MergeChangelog(JsonObject uploaded, JsonObject? current, string version)
{
var received = ChangelogStore.ChangelogArray(uploaded);
if (current is null) return received?.DeepClone().AsArray();
static bool Is(JsonNode? node, string version) =>
node is JsonObject entry && PublishedFiles.StringValue(entry, "version") == version;
var history = ChangelogStore.ChangelogArray(current) ?? new JsonArray();
var merged = new JsonArray();
var head = received?.FirstOrDefault(n => Is(n, version)) ?? history.FirstOrDefault(n => Is(n, version));
if (head is not null) merged.Add(head.DeepClone());
foreach (var entry in history)
{
if (merged.Count >= ChangelogLimits.MaxEntries) break;
if (entry is JsonObject && !Is(entry, version)) merged.Add(entry.DeepClone());
}
// Budget de tout l'historique (contrôle de lecture en même temps : une entrée illisible empêche la publication).
while (merged.Count > 1 &&
ChangelogStore.Entries(merged).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems) > ChangelogLimits.MaxTotalItems)
merged.RemoveAt(merged.Count - 1);
return merged;
}
/// <summary>Empreinte de la version en ligne hors notes et hors signature (une correction de notes ou une signature reste permise pendant l'envoi) ; vide si elle n'existe pas.</summary>
private static string OnlineContentHash(JsonObject? root)
{
if (root is null) return "";
var copy = root.DeepClone().AsObject();
PublishedFiles.SetProperty(copy, "changelog", null);
PublishedFiles.SetProperty(copy, "signature", null);
return PublishedFiles.Sha256(System.Text.Encoding.UTF8.GetBytes(copy.ToJsonString()));
}
// ================================================================== Plan (après réception du manifest)
/// <summary>Manifest complet : contrôle, puis liste des objets et de l'archive à envoyer.</summary>
private void EnsurePlan(PublicationRecord record)
{
if (record.Plan is not null) return;
var staging = Staging(record);
var manifest = Path.Combine(staging, "manifest.json");
if (!File.Exists(manifest)) return;
var (manifestPath, gameId) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var errors = new List<string>();
var summary = ManifestCheck.Check(File.ReadAllBytes(manifest), gameId, record.Version, errors);
if (summary is null)
{
Delete(record); // manifest refusé : la publication est abandonnée (à refaire avec un manifest corrigé)
throw ApiRejection.BadRequest("Manifest refusé : " + string.Join(" ", errors.Take(5)) +
(errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
}
var pending = summary.Objects
.Where(o => !ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(o.Key)), o.Value))
.ToDictionary(o => o.Key, o => o.Value, StringComparer.Ordinal);
// Archive déjà en ligne (même nom, même taille, même empreinte DANS le manifest en ligne) : pas renvoyée.
// Aucune lecture du fichier (plusieurs Go) pendant la requête.
var archiveTarget = new FileInfo(Path.Combine(gameDirectory, summary.ArchiveName));
var archiveNeeded = !(archiveTarget.Exists && archiveTarget.LinkTarget is null && archiveTarget.Length == summary.ArchiveSize &&
PublishedArchive(manifestPath, gameId) is { } online &&
string.Equals(online.Name, summary.ArchiveName, StringComparison.Ordinal) && online.Sha256 == summary.ArchiveSha256);
var needed = pending.Values.Sum() + (archiveNeeded ? summary.ArchiveSize : 0);
var free = new DriveInfo(gameDirectory).AvailableFreeSpace;
if (free < needed + FreeSpaceMargin)
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage,
$"Espace disque insuffisant sur le serveur : {needed / (1024 * 1024)} Mo à recevoir, {free / (1024 * 1024)} Mo libres.");
Save(record with
{
Plan = new PublicationPlan
{
ArchiveName = summary.ArchiveName,
ArchiveSize = summary.ArchiveSize,
ArchiveSha256 = summary.ArchiveSha256,
ArchiveNeeded = archiveNeeded,
Pending = pending,
ObjectCount = summary.Objects.Count,
ObjectsOnServer = summary.Objects.Count - pending.Count
}
});
}
/// <summary>Archive référencée par le manifest en ligne (nom, empreinte), sans la lire.</summary>
private static (string Name, string Sha256)? PublishedArchive(string manifestPath, string gameId)
{
var bytes = PublishedFiles.ReadManifest(manifestPath);
if (bytes is null) return null;
var root = ChangelogStore.Parse(bytes, gameId);
if (PublishedFiles.Property(root, "archive") is not JsonObject archive) return null;
var name = PublishedFiles.StringValue(archive, "url");
var sha = PublishedFiles.StringValue(archive, "sha256");
return name is null || sha is null ? null : (name, sha.ToLowerInvariant());
}
private PublicationPlan RequirePlan(PublicationRecord record)
{
EnsurePlan(record);
return Current(record.Id).Plan ?? throw ApiRejection.Conflict("Envoyez d'abord le manifest.");
}
private PublicationState State(PublicationRecord record, bool resumed)
{
var staging = Staging(record);
var manifestDone = File.Exists(Path.Combine(staging, "manifest.json"));
var plan = record.Plan;
var pending = new List<string>();
var partial = new Dictionary<string, long>(StringComparer.Ordinal);
long archiveReceived = 0;
var archiveComplete = false;
if (plan is not null)
{
var (manifestPath, _) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var objects = Path.Combine(staging, "objects");
foreach (var (sha, size) in plan.Pending)
{
if (File.Exists(Path.Combine(objects, sha + ".bin")) ||
ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
continue;
pending.Add(sha);
var part = new FileInfo(Path.Combine(objects, sha + ".part"));
if (part.Exists && part.Length > 0) partial[sha] = part.Length;
}
archiveComplete = !plan.ArchiveNeeded || File.Exists(Path.Combine(staging, "archive.zip"));
archiveReceived = archiveComplete ? plan.ArchiveSize : Length(Path.Combine(staging, "archive.part"));
}
return new PublicationState
{
PublicationId = record.Id,
GameId = record.GameId,
Version = record.Version,
PublishedVersion = record.PublishedVersion,
Resumed = resumed,
ManifestSize = record.ManifestSize,
ManifestReceived = manifestDone ? record.ManifestSize : Length(Path.Combine(staging, "manifest.part")),
ManifestAccepted = plan is not null,
ObjectCount = plan?.ObjectCount ?? 0,
ObjectsOnServer = plan?.ObjectsOnServer ?? 0,
PendingObjects = pending,
PartialObjects = partial,
ArchiveName = plan?.ArchiveName,
ArchiveSize = plan?.ArchiveSize ?? 0,
ArchiveReceived = archiveReceived,
ArchiveComplete = archiveComplete,
ExpiresAt = LastActivity(record.Id) + PublishingLimits.IdleExpiry
};
}
// ================================================================== Réception d'un fichier
/// <summary>
/// Ajoute un morceau à la suite de ce qui a été reçu (position vérifiée), puis vérifie l'empreinte
/// quand le fichier est complet (calculée au fil des morceaux ; recalculée après un redémarrage).
/// </summary>
private ChunkResult Append(string partPath, string donePath, long offset, byte[] data, long size, string sha256, string what)
{
if (File.Exists(donePath)) return new ChunkResult { Received = size, Complete = true };
var received = Length(partPath);
if (offset != received)
throw ApiRejection.Conflict($"Position d'envoi {offset} inattendue ({what} : {received} octets déjà reçus).", ApiErrorCodes.OffsetMismatch);
if (received + data.LongLength > size) throw ApiRejection.BadRequest($"Données en trop ({what} : {size} octets attendus).");
if (data.Length == 0 && size > 0) throw ApiRejection.BadRequest("Morceau vide.");
var options = new FileStreamOptions { Mode = FileMode.Append, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
using (var stream = new FileStream(partPath, options))
{
stream.Write(data);
stream.Flush(flushToDisk: true);
}
HashProgress? progress;
if (received == 0)
{
RemoveHash(partPath);
progress = _hashes[partPath] = new HashProgress();
}
else if (!_hashes.TryGetValue(partPath, out progress) || progress.Offset != received)
{
RemoveHash(partPath);
progress = null;
}
progress?.Add(data);
received += data.Length;
if (received < size) return new ChunkResult { Received = received };
var actual = progress is not null && progress.Offset == size ? progress.Finish() : HashFile(partPath);
RemoveHash(partPath);
if (actual != sha256)
{
PublishedFiles.TryDelete(partPath);
throw ApiRejection.BadRequest($"Empreinte différente pour {what} : fichier abîmé ou modifié pendant l'envoi ; il sera renvoyé.");
}
File.Move(partPath, donePath, overwrite: true);
return new ChunkResult { Received = size, Complete = true };
}
private void RemoveHash(string key)
{
if (_hashes.TryRemove(key, out var progress)) progress.Dispose();
}
private sealed class HashProgress : IDisposable
{
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
public long Offset { get; private set; }
public void Add(byte[] data)
{
_hash.AppendData(data);
Offset += data.Length;
}
public string Finish() => Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
public void Dispose() => _hash.Dispose();
}
// ================================================================== Enregistrements, dépôt
public sealed record PublicationRecord
{
public string Id { get; init; } = "";
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string ManifestSha256 { get; init; } = "";
public long ManifestSize { get; init; }
public Guid AdminId { get; init; }
public string AdminName { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Version en ligne au début (vide : aucune).</summary>
public string PublishedVersion { get; init; } = "";
/// <summary>Empreinte du manifest en ligne SANS les notes, au début : la mise en ligne est refusée si elle a changé.</summary>
public string PublishedContentSha256 { get; init; } = "";
public PublicationPlan? Plan { get; init; }
}
public sealed record PublicationPlan
{
public string ArchiveName { get; init; } = "";
public long ArchiveSize { get; init; }
public string ArchiveSha256 { get; init; } = "";
public bool ArchiveNeeded { get; init; }
/// <summary>Objets absents du serveur quand le manifest a été reçu (empreinte → taille).</summary>
public Dictionary<string, long> Pending { get; init; } = new(StringComparer.Ordinal);
public int ObjectCount { get; init; }
public int ObjectsOnServer { get; init; }
}
/// <summary>Publication terminée : résultat gardé 24 h (réponse perdue, nouvel essai).</summary>
public sealed record PublicationDone
{
public string Id { get; init; } = "";
public Guid AdminId { get; init; }
public PublicationResult Result { get; init; } = new();
public DateTimeOffset At { get; init; }
}
private (string Path, string GameId) Locate(string gameId)
{
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw PublishedFiles.NotConfigured(gameId, "Publication");
}
/// <summary>Publication de cet administrateur, encore valable (sinon 404/403).</summary>
private PublicationRecord Find(string id, Guid adminId)
{
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
CleanUp(force: false);
var record = Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
if (record.AdminId != adminId)
throw new ApiRejection(403, ApiErrorCodes.Forbidden, $"Cette publication a été lancée par {record.AdminName}.");
return record;
}
/// <summary>Relue sous le verrou : abandonnée entre-temps → 404.</summary>
private PublicationRecord Current(string id) =>
Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
private string RecordPath(string id) => Path.Combine(_recordsDirectory, id + ".json");
private string DonePath(string id) => Path.Combine(_recordsDirectory, id + DoneSuffix + ".json");
private PublicationRecord? Load(string id) => Read<PublicationRecord>(RecordPath(id));
private PublicationDone? LoadDone(string id) => Read<PublicationDone>(DonePath(id));
private static T? Read<T>(string path) where T : class
{
if (!File.Exists(path)) return null;
try
{
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), ApiJson.Options);
}
catch (Exception ex) when (ex is JsonException or IOException)
{
return null;
}
}
private IEnumerable<PublicationRecord> Records()
{
if (!Directory.Exists(_recordsDirectory)) yield break;
foreach (var file in Directory.GetFiles(_recordsDirectory, "*.json"))
{
var id = Path.GetFileNameWithoutExtension(file);
if (PublishingLimits.IsValidPublicationId(id) && Load(id) is { } record) yield return record;
}
}
private void Save(PublicationRecord record)
{
SecureFiles.EnsureDirectory(_recordsDirectory);
SecureFiles.WriteAllTextAtomic(RecordPath(record.Id), JsonSerializer.Serialize(record, ApiJson.Options), keepBackup: false);
}
private void SaveDone(PublicationDone done)
{
SecureFiles.EnsureDirectory(_recordsDirectory);
SecureFiles.WriteAllTextAtomic(DonePath(done.Id), JsonSerializer.Serialize(done, ApiJson.Options), keepBackup: false);
}
/// <summary>Dernier envoi : date de modification de l'enregistrement (mise à jour à chaque morceau).</summary>
private DateTimeOffset LastActivity(string id)
{
var path = RecordPath(id);
return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero) : DateTimeOffset.MinValue;
}
private void Touch(string id)
{
try
{
File.SetLastWriteTimeUtc(RecordPath(id), _time.GetUtcNow().UtcDateTime);
}
catch (IOException)
{
// simple indication d'activité
}
}
private string Staging(PublicationRecord record) =>
Path.Combine(Path.GetDirectoryName(Locate(record.GameId).Path)!, StagingPrefix + record.Id);
private string StagingObjects(PublicationRecord record)
{
var objects = Path.Combine(Staging(record), "objects");
if (!Directory.Exists(objects)) CreatePrivateDirectory(objects);
return objects;
}
private void CreateStaging(PublicationRecord record)
{
var staging = Staging(record);
if (new DirectoryInfo(Path.GetDirectoryName(staging)!).LinkTarget is not null)
throw new ManifestWriteRefusedException("le dossier du jeu est un lien symbolique.");
CreatePrivateDirectory(staging);
}
private static void CreatePrivateDirectory(string path)
{
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
else Directory.CreateDirectory(path, PrivateDirectory);
}
private void Delete(PublicationRecord record)
{
foreach (var key in _hashes.Keys.Where(k => k.Contains(StagingPrefix + record.Id, StringComparison.Ordinal)).ToList())
RemoveHash(key);
try
{
var staging = Staging(record);
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
// dépôt laissé : supprimé au prochain nettoyage
}
PublishedFiles.TryDelete(RecordPath(record.Id));
_locks.TryRemove(record.Id, out _);
}
/// <summary>
/// Publications sans envoi depuis 48 h, résultats de plus de 24 h et dépôts orphelins : supprimés
/// (au début d'une publication, et au plus toutes les 10 minutes lors des envois).
/// </summary>
private void CleanUp(bool force)
{
var now = _time.GetUtcNow();
lock (_locks)
{
if (!force && now - _lastCleanUp < CleanUpEvery) return;
_lastCleanUp = now;
}
var known = new HashSet<string>(StringComparer.Ordinal);
foreach (var record in Records().ToList())
{
if (now - LastActivity(record.Id) > PublishingLimits.IdleExpiry)
Lock(record.Id, () =>
{
Delete(record);
return 0;
});
else known.Add(record.Id);
}
if (Directory.Exists(_recordsDirectory))
foreach (var done in Directory.GetFiles(_recordsDirectory, "*" + DoneSuffix + ".json"))
if (now - new DateTimeOffset(File.GetLastWriteTimeUtc(done), TimeSpan.Zero) > DoneLifetime)
PublishedFiles.TryDelete(done);
foreach (var manifest in _manifests.Values)
{
var directory = Path.GetDirectoryName(manifest)!;
if (!Directory.Exists(directory)) continue;
foreach (var staging in Directory.GetDirectories(directory, StagingPrefix + "*"))
{
var id = Path.GetFileName(staging)[StagingPrefix.Length..];
if (known.Contains(id) || now - Directory.GetLastWriteTimeUtc(staging) <= PublishingLimits.IdleExpiry) continue;
try
{
Directory.Delete(staging, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// nouvel essai au prochain nettoyage
}
}
}
}
private T Lock<T>(string id, Func<T> action)
{
lock (_locks.GetOrAdd(id, _ => new object())) return action();
}
private static bool ObjectPresent(string path, long size)
{
var info = new FileInfo(path);
return info.Exists && info.LinkTarget is null && info.Length == size;
}
private static long Length(string path)
{
var info = new FileInfo(path);
return info.Exists ? info.Length : 0;
}
private static string HashFile(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,281 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichiers PUBLICS d'un jeu (dossier servi par nginx : manifest.json, archive, objects/), communs aux
/// notes de version (phase 4) et à la publication d'une version (phase 5) :
/// • même verrou par jeu (une seule écriture du manifest à la fois, service et outils confondus) ;
/// • même présentation JSON que ManifestBuilder ;
/// • écriture atomique : fichier temporaire caché (refusé par nginx) dans le même dossier, puis renommage ;
/// • droits : ceux du manifest existant (sinon 0640 avec le groupe du dossier setgid, ou 0644) ;
/// refus si le dossier n'a pas le bit setgid alors que nginx lit les fichiers par leur groupe,
/// ou si le manifest est un lien symbolique.
/// </summary>
internal static class PublishedFiles
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = 64L * 1024 * 1024;
public static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
private const string HistoryFolder = "manifest-history";
private const int HistoryLength = 20;
private const UnixFileMode GroupReadable = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
private const UnixFileMode AllReadable = GroupReadable | UnixFileMode.OtherRead;
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
public static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
MaxDepth = 64
};
public static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
/// <summary>Verrou d'écriture du manifest d'un jeu (fichier dans les données privées).</summary>
public static FileStream LockGame(string dataDirectory, string gameId) =>
SecureFiles.AcquireLock(Path.Combine(dataDirectory, $"manifest-{gameId.ToLowerInvariant()}.lock"), LockTimeout);
public static string Sha256(ReadOnlySpan<byte> bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
public static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
/// <summary>Manifest publié (null s'il n'existe pas encore).</summary>
public static byte[]? ReadManifest(string path)
{
var info = new FileInfo(path);
if (!info.Exists) return null;
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
return File.ReadAllBytes(path);
}
public static JsonObject ParseObject(ReadOnlySpan<byte> bytes, string what)
{
try
{
return JsonNode.Parse(bytes, documentOptions: ReadOptions) as JsonObject
?? throw new InvalidDataException($"{what} : objet JSON attendu.");
}
catch (Exception ex) when (ex is JsonException or ArgumentException)
{
throw new InvalidDataException($"{what} illisible : {ex.Message}", ex);
}
}
/// <summary>Propriété d'un objet JSON, nom insensible à la casse (comme pour le launcher).</summary>
public static JsonNode? Property(JsonObject node, string name)
{
foreach (var (key, value) in node)
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
return value;
return null;
}
public static string? StringValue(JsonObject node, string name) =>
Property(node, name) is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
/// <summary>Remplace une propriété (toutes casses confondues) ; null la retire.</summary>
public static void SetProperty(JsonObject node, string name, JsonNode? value)
{
foreach (var existing in node.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
node.Remove(existing);
if (value is not null) node[name] = value;
}
public static byte[] Serialize(JsonObject root)
{
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(root.ToJsonString(ManifestJson));
if (bytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
return bytes;
}
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération ; 20 dernières).</summary>
public static void Backup(string dataDirectory, string gameId, byte[] bytes)
{
try
{
var directory = Path.Combine(dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
SecureFiles.EnsureDirectory(Path.Combine(dataDirectory, HistoryFolder));
SecureFiles.EnsureDirectory(directory);
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// La copie est une aide : son échec n'empêche pas l'écriture.
}
}
/// <summary>
/// Droits d'un nouveau fichier public du dossier : ceux du manifest existant, sinon lisible par le
/// groupe du dossier (setgid) ou par tous. Refus si nginx risque de perdre l'accès.
/// </summary>
public static UnixFileMode PublicMode(string directory, string? referenceFile)
{
if (OperatingSystem.IsWindows()) return AllReadable;
var directoryMode = File.GetUnixFileMode(directory);
var setgid = directoryMode.HasFlag(UnixFileMode.SetGroup);
var mode = referenceFile is not null && File.Exists(referenceFile)
? File.GetUnixFileMode(referenceFile)
: setgid ? GroupReadable : AllReadable;
// Le fichier créé appartient au compte du service, avec le groupe du dossier SEULEMENT si celui-ci
// a le bit setgid : sinon nginx (groupe www-data) ne pourrait plus lire un fichier lisible par le groupe.
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) && !setgid)
throw new ManifestWriteRefusedException(
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : les fichiers écrits ne seraient plus lisibles par nginx.");
// Fichier existant : ses droits sont repris tels quels (rw des comptes compris) ; nouveau fichier : lecture.
return mode & (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
}
/// <summary>
/// Sous-dossier public (objects, objects/ab) : droits du dossier du jeu, fixés explicitement (le masque
/// du service, UMask=0077, retirerait l'accès de nginx). Refus s'il s'agit d'un lien symbolique.
/// <paramref name="keepSetGid"/> : dossier qui en contiendra d'autres (objects) ; son bit setgid doit être
/// conservé pour que les sous-dossiers créés ensuite aient le groupe lu par nginx. (Un dossier
/// « feuille » garde de toute façon le groupe hérité à sa création, comme les fichiers qui y sont déplacés.)
/// </summary>
public static void EnsurePublicDirectory(string path, string gameDirectory, bool keepSetGid)
{
var info = new DirectoryInfo(path);
if (info.LinkTarget is not null)
throw new ManifestWriteRefusedException($"« {Path.GetFileName(path)} » est un lien symbolique dans le dossier du jeu.");
if (info.Exists) return;
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(path);
return;
}
var gameMode = File.GetUnixFileMode(gameDirectory);
var permissions = gameMode & PermissionBits;
var needSetGid = keepSetGid && gameMode.HasFlag(UnixFileMode.SetGroup);
// Jamais de bit spécial demandé : systemd l'interdit au service (RestrictSUIDSGID=yes, refus EPERM).
// Dans un dossier setgid, le noyau transmet de lui-même le bit setgid et le groupe (www-data).
Directory.CreateDirectory(path, permissions);
if ((File.GetUnixFileMode(path) & PermissionBits) != permissions) // réduits par le masque du service (UMask=0077)
{
try
{
// Fixer les droits retire le setgid hérité : sans conséquence pour un dossier « feuille » (son
// groupe reste celui hérité) ; « objects » doit le redemander, ce que systemd peut refuser.
File.SetUnixFileMode(path, needSetGid ? permissions | UnixFileMode.SetGroup : permissions);
}
catch (UnauthorizedAccessException) when (needSetGid)
{
// traité ci-dessous
}
}
// Sans setgid (ou si le compte du service n'appartient pas au groupe du dossier, Linux le retire), les
// sous-dossiers créés ensuite n'auraient plus le groupe lu par nginx.
if (needSetGid && File.GetUnixFileMode(path) != (permissions | UnixFileMode.SetGroup))
{
TryDeleteDirectory(path);
throw new ManifestWriteRefusedException(
"le dossier « objects » n'existe pas encore et le service ne peut pas le créer avec le bit setgid et l'accès de nginx : " +
"créez-le sur le VPS (server/deploy/LISEZMOI.md, étape 3 quater).");
}
}
/// <summary>Droits rwx des trois classes, sans setuid, setgid ni sticky.</summary>
private const UnixFileMode PermissionBits =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
private static void TryDeleteDirectory(string path)
{
try
{
Directory.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé : vide, sans conséquence
}
}
/// <summary>
/// Remplace (ou crée) le manifest publié. <paramref name="expectedSha256"/> : empreinte du manifest
/// sur lequel l'écriture a été préparée (null : il ne devait pas exister) ; revérifiée juste avant le
/// renommage, pour ne jamais écraser un envoi (WinSCP…) fait entre-temps.
/// </summary>
public static void WriteManifest(string path, byte[] bytes, string? expectedSha256, Func<ApiRejection> conflict)
{
var directory = Path.GetDirectoryName(path)!;
if (new FileInfo(path).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
var mode = PublicMode(directory, path);
var temp = TempPath(path);
try
{
WriteNew(temp, bytes, mode);
var current = ReadManifest(path);
if (current is null ? expectedSha256 is not null : !string.Equals(Sha256(current), expectedSha256, StringComparison.OrdinalIgnoreCase))
throw conflict();
File.Move(temp, path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
TryDelete(temp);
if (ex is ApiRejection or ManifestWriteRefusedException) throw;
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
}
}
/// <summary>Fichier temporaire caché du même dossier (nginx refuse les noms commençant par un point).</summary>
public static string TempPath(string path) =>
Path.Combine(Path.GetDirectoryName(path)!, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
public static void WriteNew(string path, ReadOnlySpan<byte> bytes, UnixFileMode mode)
{
using (var stream = new FileStream(path, new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, mode);
}
public static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé en place : sans conséquence (nom unique, caché)
}
}
public static ApiRejection NotConfigured(string gameId, string what) => new(503, ApiErrorCodes.NotConfigured,
$"{what} impossible pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
}
/// <summary>Écriture refusée par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
public sealed class ManifestWriteRefusedException : IOException
{
public ManifestWriteRefusedException(string message) : base(message) { }
}
@@ -31,12 +31,24 @@
"PublicFile": "",
"PublicUrl": ""
},
// Trousseau (phase 7) : vide = « keyring.json » à côté du catalogue (même dossier, déjà autorisé en écriture).
"Keyring": {
"PublicFile": "",
"PublicUrl": ""
},
// Signature du contenu : true = catalogue et versions refusés s'ils ne sont pas signés (une fois les
// launchers en mode « require », voir server/deploy/LISEZMOI.md, étape 3 quinquies).
"ContentSigning": {
"Require": false
},
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
"Games": {},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
"PublicationUploadsPerMinute": 1500,
"AuthPerMinute": 20,
"EnrollmentsPerHour": 10,
"MaxActiveInvitations": 20,
+136 -15
View File
@@ -173,6 +173,123 @@ le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
## 3 quater. Publier une version depuis le launcher (VPS)
La page **Publier une version** du menu d'administration remplace l'envoi avec WinSCP : seuls les
fichiers absents du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est mis
en ligne en dernier. Prérequis : l'étape 3 ter (dossier du jeu déclaré dans `LauncherApi:Games`,
complément `games.conf`, bit setgid).
1. nginx : ajoute la zone de limite des envois dans `/etc/nginx/conf.d/securite.conf`, à côté de
`api_par_ip` (un envoi enchaîne plusieurs morceaux de 4 Mo par seconde ; avec la limite générale de
l'API, il serait bridé et ferait des pauses) :
```
limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
```
Puis colle le bloc `location ^~ /api/v1/publications/ { … }` de `server/deploy/nginx-publications.conf`
dans le bloc HTTPS du site, à côté du bloc `/api/`, et recharge :
```
sudo nginx -t && sudo systemctl reload nginx
```
Seules ces routes acceptent des envois de 8 Mo ; le reste de l'API reste limité à 64 Ko. Vérifie
aussi que le site contient toujours la règle `location ~ /\. { deny all; }` : les fichiers en cours
d'envoi sont rangés dans un dossier caché (`.publish-…`) du dossier du jeu.
2. Crée le dossier `objects` du jeu **et ses 256 sous-dossiers** (`00` à `ff`), avec le groupe et le bit
setgid du dossier du jeu (sans risque s'ils existent déjà : leurs droits sont simplement remis en état) :
```
JEU=/srv/exovival/www/exovivalzombie
sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects"
for i in $(seq 0 255); do sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects/$(printf %02x $i)"; done
```
Pourquoi : systemd interdit au service de demander le bit setgid (`RestrictSUIDSGID=yes`, gardé). Un
sous-dossier qui manquerait serait créé par le service en `drwxr-x---` (groupe `www-data`, lisible par
nginx) mais **sans setgid** : un fichier déposé ensuite dedans **avec WinSCP** prendrait le groupe
`deploy` et ne serait plus lisible par nginx. Relance la boucle si `stat` montre un `drwxr-x---`.
3. Espace disque : l'envoi est stocké sur le même disque que le jeu jusqu'à la mise en ligne. Prévois la
taille de l'archive et des nouveaux fichiers (le serveur refuse l'envoi s'il manque de place).
4. Teste : lance le .bat de publication (ManifestBuilder) sur ton PC, puis menu d'administration ›
**Publier une version** › « Choisir le manifest.json… » (dossier `--output`) › **Publier**.
> **Déjà fait avant la correction des pauses ?** Ajoute la ligne `limit_req_zone … publication_par_ip …`
> ci-dessus, remplace dans le bloc `/api/v1/publications/` du site `limit_req zone=api_par_ip burst=20 nodelay;`
> par `limit_req zone=publication_par_ip burst=50 nodelay;`, recharge nginx, puis mets à jour le serveur
> (étape 8) : il a désormais sa propre limite pour les envois (`LauncherApi:Limits:PublicationUploadsPerMinute`,
> 1500 par minute par défaut, rien à ajouter dans `appsettings.Production.json`).
Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cliques de nouveau sur
« Publier » avec le même dossier ; sans reprise, elle est supprimée au bout de 48 h. WinSCP reste
possible, mais n'envoie pas une version avec les deux en même temps : le serveur refuse alors de
mettre la sienne en ligne plutôt que d'écraser l'autre.
## 3 quinquies. Signer ce que reçoivent les joueurs (PC et VPS)
Sans signature, quelqu'un qui prendrait le contrôle du VPS pourrait distribuer un faux jeu. Avec la
signature, chaque version mise en ligne (fichiers, archive, exécutable) et le catalogue portent la
signature de l'administrateur qui les a publiés. Les launchers ne font confiance qu'aux clés du
**trousseau**, lui-même signé **hors ligne** par la clé racine du launcher (`launcher-signing.key`, celle des
mises à jour du launcher). Le VPS ne détient aucune de ces clés privées.
Prérequis : étapes 3 bis (catalogue : le trousseau est écrit dans le même dossier) et 3 quater.
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le trousseau
est écrit à côté du catalogue (`/srv/exovival/www/catalog/keyring.json`).
2. **nginx** : ajoute le bloc `location = /catalog/keyring.json { … }` de `server/deploy/nginx-catalog.conf` à côté
de celui du catalogue, puis `sudo nginx -t && sudo systemctl reload nginx`.
3. **Trousseau** (PC) :
1. menu d'administration › **Signature** › « Exporter la demande… » : enregistre `keyring-request-1.json`
(les appareils actifs des administrateurs autorisés à publier) ;
2. glisse ce fichier sur `tools\sign-keyring.bat` : la demande est comparée au trousseau signé précédent
(`keyring-N.json` du même dossier) et chaque **NOUVELLE CLÉ** (ou autorisation élargie) est signalée.
**Vérifie ces empreintes hors du serveur** : la demande vient du VPS, un serveur compromis pourrait y glisser
sa propre clé. Fais lire à chaque personne l'empreinte de son appareil (page **Mes appareils**, calculée sur
son PC) de vive voix ou par téléphone, et compare. Puis la phrase de passe de la clé racine → `keyring-N.json`
(garde ce fichier dans le même dossier : il servira de référence la fois suivante) ;
3. page **Signature** › « Publier le trousseau signé… » : le launcher vérifie la signature racine avant de l'envoyer.
Les noms des appareils (« Nicolas — PC fixe ») sont **publics** : le trousseau est lisible par tous.
4. **Contenu déjà en ligne** :
* version du jeu : page **Publier une version** › « Choisir le manifest.json… » du dossier de **la version
en ligne** › « Signer la version en ligne » (le serveur vérifie que les fichiers sont identiques) ;
* catalogue : page **Jeux** › « Publier » (il est signé à chaque publication).
La page **Signature** doit alors tout indiquer « valide pour les launchers ».
5. **Launcher** : dans `src\GameLauncher\launcher.json`, décommente `"keyringUrl"` (adresse affichée par la page
Signature) et `"contentSigning": "warn"`, puis publie le launcher (mise à jour signée habituelle). En « warn »,
les problèmes sont seulement inscrits au journal du launcher (`%AppData%\<éditeur>\GameLauncher\logs\launcher.log`) : vérifie qu'il
indique « Manifest de EZR … signé par … » et « Trousseau n° 1 vérifié ».
6. **Protection complète** : passe à `"contentSigning": "require"` et publie le launcher : un contenu non signé,
mal signé ou plus ancien que le dernier vu est alors refusé (le jeu installé reste jouable). Optionnel, sur le
VPS : `"ContentSigning": { "Require": true }` dans la section `LauncherApi` d'`appsettings.Production.json`
(puis `sudo systemctl restart launcher-api`) pour que le serveur refuse toute publication non signée.
Ensuite, les nouvelles versions et le catalogue sont signés automatiquement à leur publication. **À refaire
(étape 3)** : à chaque nouvel administrateur ou nouvel appareil qui doit publier (avant sa première publication),
et à chaque révocation (la page Signature signale le contenu signé par une clé qui va disparaître : re-signe-le
d'abord). Une version envoyée avec WinSCP doit être signée ensuite (étape 4).
**Clé compromise** (appareil volé…) : révoque l'appareil, signe et publie un nouveau trousseau (étape 3), puis
mets son numéro dans `"keyringMinSequence"` de `launcher.json` et publie le launcher. Sans cela, un launcher qui
n'a encore jamais vu le nouveau trousseau (installation neuve, cache effacé) accepterait encore l'ancien,
toujours signé par la clé racine, si un serveur compromis le lui présentait.
Limites :
* les images du catalogue ne sont pas signées (un serveur compromis pourrait les remplacer, pas les liens ni les
textes) ;
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
et au moins celui du dernier trousseau qu'il a vu ;
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
à la fois les mises à jour du launcher et le trousseau.
## 4. Créer ta clé d'administrateur (PC)
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
@@ -216,26 +333,29 @@ Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste d
## 7. Ajouter un administrateur
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) :
Tout se fait dans le launcher (menu d'administration), sans ligne de commande :
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** : 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
1. **Toi** : page **Administrateurs** › « Inviter un administrateur » (nom, rôle : propriétaire,
administrateur ou éditeur, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable
24 h, usage unique, affiché une seule fois).
2. **Paul** : launcher › menu d'administration › « J'ai une invitation », code collé ; 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>"`.
3. **Toi** : page **Administrateurs** › « Actualiser » : le compte de Paul apparaît en premier, en
attente ; saisis l'empreinte dictée sous son appareil, puis « Approuver ».
En ligne de commande, `tools\admin-key.bat` fait la même chose : `invite --api https://launcher.lanisys.fr/api/
--name "Paul" --role editor`, puis `admins --api …` et `approve --api … --admin <id> --key-id <clé>
--fingerprint "<empreinte>"`.
Le serveur n'affiche pas l'empreinte des clés en attente : l'approbation n'est possible qu'avec
celle que Paul te donne. Si elle ne correspond pas, **n'insiste pas** : quelqu'un d'autre a pu
utiliser le code. Révoque (`revoke-admin`) et recommence.
utiliser le code. Révoque ce compte (page Administrateurs, ou `revoke-admin`) et recommence.
Nouveau PC pour un administrateur existant : **lui-même** lance `invite --api … --for <son id>`
depuis un PC déjà enregistré, `enroll` sur le nouveau PC, puis `approve` depuis l'ancien. Personne
d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
Nouveau PC pour un administrateur existant : **lui-même**, depuis un PC déjà enregistré, page **Mes
appareils** › « Ajouter un appareil » ; sur le nouveau PC, « J'ai une invitation » ; puis, de retour sur
l'ancien, approbation de l'appareil en attente avec l'empreinte affichée par le nouveau (en ligne de
commande : `invite --for <son id>`, `enroll`, `approve`). Personne d'autre ne peut approuver une clé à
son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
## 8. Mettre à jour le serveur
@@ -292,7 +412,8 @@ recréer les administrateurs.
├─ audit/audit-AAAA-MM.jsonl journal d'audit
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
├─ catalog-history/ 20 dernières versions publiées
├─ manifest-history/<jeu>/ manifests remplacés par la page Notes de version (20 derniers)
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
├─ publications/ publications en cours (version, fichiers restant à envoyer)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
+9 -1
View File
@@ -1,5 +1,5 @@
# =============================================================================
# Catalogue des jeux (écrit par la Launcher API) — un ajout (voir LISEZMOI.md, étape 3 bis) :
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
@@ -18,3 +18,11 @@ location = /catalog/catalog.json {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Trousseau (phase 7) : clés autorisées à signer versions et catalogue, signé par la clé racine du launcher.
location = /catalog/keyring.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
+39
View File
@@ -0,0 +1,39 @@
# =============================================================================
# Publication d'une version depuis le launcher — deux ajouts (voir LISEZMOI.md, étape 3 quater) :
#
# 1) Dans /etc/nginx/conf.d/securite.conf, à côté de la zone « api_par_ip » :
#
# limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
#
# Un envoi enchaîne plusieurs morceaux de 4 Mo par seconde : la zone « api_par_ip » (5 par seconde)
# briderait l'envoi vers 20 Mo/s et provoquerait des pauses. 25 par seconde ≈ 100 Mo/s ; ces routes
# exigent une session d'administrateur et le droit de publier.
#
# 2) Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival, à côté du bloc
# « location ^~ /api/ { … } » (nginx-api.conf) : (le bloc ci-dessous)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
#
# Seules ces routes acceptent de gros envois (morceaux de 4 Mo, 8 Mo au plus) ; toutes les autres
# routes de l'API restent limitées à 64 Ko. Le préfixe le plus long l'emporte sur « /api/ ».
# Les fichiers en cours d'envoi sont rangés dans un dossier caché (.publish-…) du dossier du jeu :
# la règle « location ~ /\. { deny all; } » du site doit être présente (elle l'est dans le guide).
# =============================================================================
location ^~ /api/v1/publications/ {
limit_req zone=publication_par_ip burst=50 nodelay;
limit_req_status 429;
client_max_body_size 9m;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 180s;
proxy_read_timeout 180s;
}
+89 -2
View File
@@ -1,9 +1,12 @@
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.ApiClient;
@@ -43,6 +46,12 @@ public sealed class AdminSession : IAsyncDisposable
public DateTimeOffset IdleExpiresAt { get; private set; }
public bool IsOpen => _token is not null;
/// <summary>Clé publique de CET appareil (SPKI), lue sur le PC : son empreinte ne dépend pas du serveur.</summary>
public byte[] PublicKey => _signer.PublicKey;
/// <summary>Empreinte de la clé de cet appareil, calculée sur le PC (à dicter pour vérifier le trousseau).</summary>
public string LocalFingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
// ------------------------------------------------------------------ Session
@@ -81,6 +90,9 @@ public sealed class AdminSession : IAsyncDisposable
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
public Task<MyAccount> GetMyAccountAsync(CancellationToken ct = default) => GetAsync<MyAccount>(ApiRoutes.MyAccount, ct);
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
@@ -137,6 +149,75 @@ public sealed class AdminSession : IAsyncDisposable
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
}
// ------------------------------------------------------------------ Publication d'une version
/// <summary>Délai d'une requête d'envoi de fichier (morceau de 4 Mo sur une connexion lente).</summary>
public static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(3);
public Task<PublicationState> StartPublicationAsync(string gameId, PublicationStartRequest request, CancellationToken ct = default) =>
SendSignedAsync<PublicationState>(HttpMethod.Post, ApiRoutes.PublicationStart(CheckGameId(gameId)), request, ct);
public Task<PublicationState> GetPublicationAsync(string id, CancellationToken ct = default) =>
GetAsync<PublicationState>(ApiRoutes.Publication(CheckPublicationId(id)), ct);
public Task CancelPublicationAsync(string id, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Publication(CheckPublicationId(id)), null, ct);
public Task<ChunkResult> UploadManifestAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationManifest(CheckPublicationId(id), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadArchiveAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationArchive(CheckPublicationId(id), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadObjectAsync(string id, string sha256, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put,
ApiRoutes.PublicationObject(CheckPublicationId(id), PublishingLimits.IsValidSha256(sha256) ? sha256.ToLowerInvariant()
: throw new ArgumentException("Empreinte invalide.", nameof(sha256)), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadObjectBatchAsync(string id, byte[] batch, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Post, ApiRoutes.PublicationObjects(CheckPublicationId(id)), batch,
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
/// <param name="signature">Signature du contenu du manifest (phase 7), ou null (version non signée).</param>
public Task<PublicationResult> CommitPublicationAsync(string id, ContentSignature? signature = null, CancellationToken ct = default) =>
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)),
signature is null ? null : JsonSerializer.SerializeToUtf8Bytes(new PublicationCommitRequest { Signature = signature }, ApiJson.Options),
signed: true, ct, timeout: UploadTimeout);
// ------------------------------------------------------------------ Signature du contenu (phase 7)
/// <summary>
/// Signe un contenu servi aux joueurs avec la clé de cette session (date : heure du serveur, à la seconde).
/// La clé privée ne quitte jamais le PC (ni la puce TPM) : seul le condensé calculé ici est signé.
/// </summary>
public async Task<ContentSignature> SignContentAsync(string kind, string id, string digest, CancellationToken ct = default)
{
var signedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset);
var message = ContentSigning.Message(kind, id, signedAt, _signer.KeyId, digest);
var signature = await _signer.SignAsync(message, ct).ConfigureAwait(false);
return new ContentSignature { KeyId = _signer.KeyId, SignedAt = signedAt, Value = Convert.ToBase64String(signature) };
}
public Task<SigningState> GetSigningStateAsync(CancellationToken ct = default) => GetAsync<SigningState>(ApiRoutes.Signing, ct);
/// <summary>Publie un trousseau signé par la clé racine (le serveur contrôle la structure et le numéro).</summary>
public Task<KeyringDocument> PublishKeyringAsync(KeyringDocument keyring, CancellationToken ct = default) =>
SendSignedAsync<KeyringDocument>(HttpMethod.Put, ApiRoutes.Keyring, keyring, ct);
public Task<ManifestSignatureState> GetManifestSignatureAsync(string gameId, CancellationToken ct = default) =>
GetAsync<ManifestSignatureState>(ApiRoutes.ManifestSignature(CheckGameId(gameId)), ct);
/// <summary>Signe la version en ligne (le serveur vérifie qu'elle a exactement le contenu signé).</summary>
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
private static string CheckPublicationId(string id) =>
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
private static string CheckGameId(string gameId) =>
CatalogRules.IsValidGameId(gameId) ? gameId : throw new ArgumentException($"Identifiant de jeu invalide : « {gameId} ».", nameof(gameId));
@@ -149,7 +230,13 @@ public sealed class AdminSession : IAsyncDisposable
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct)
/// <summary>Requête qui modifie avec un corps binaire (morceau de fichier), signée par la clé.</summary>
public Task<T> SendSignedBytesAsync<T>(HttpMethod method, string route, byte[] body, string contentType, TimeSpan timeout,
CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
{
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
@@ -175,7 +262,7 @@ public sealed class AdminSession : IAsyncDisposable
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
}
}, ct).ConfigureAwait(false);
}, ct, contentType, timeout).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
@@ -17,5 +17,8 @@ public sealed class ApiException : Exception
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
public string Code { get; }
/// <summary>Délai annoncé par le serveur avant un nouvel essai (en-tête Retry-After), s'il y en a un.</summary>
public TimeSpan? RetryAfter { get; init; }
public bool IsUnauthorized => Status == 401;
}
+44 -15
View File
@@ -41,7 +41,8 @@ public sealed class LauncherApiClient : IDisposable
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
}, disposeHandler: handler is null)
{
Timeout = RequestTimeout
// Délai propre à chaque requête (SendAsync) : 30 s par défaut, plus pour l'envoi de fichiers.
Timeout = Timeout.InfiniteTimeSpan
};
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -129,33 +130,50 @@ public sealed class LauncherApiClient : IDisposable
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
Action<HttpRequestMessage>? prepare, CancellationToken ct)
Action<HttpRequestMessage>? prepare, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
{
using var request = new HttpRequestMessage(method, Resolve(route));
if (body is not null)
{
request.Content = new ByteArrayContent(body);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
request.Content.Headers.ContentType = contentType == "application/json"
? new MediaTypeHeaderValue(contentType) { CharSet = "utf-8" }
: new MediaTypeHeaderValue(contentType);
}
prepare?.Invoke(request);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout ?? RequestTimeout);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
}
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
using (response)
{
var bytes = await ReadBoundedAsync(response, ct).ConfigureAwait(false);
byte[] bytes;
try
{
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Connexion au serveur d'administration interrompue ({ex.Message}).", ex);
}
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
try
@@ -191,25 +209,36 @@ public sealed class LauncherApiClient : IDisposable
private static ApiException ToException(HttpResponseMessage response, byte[] body)
{
var status = (int)response.StatusCode;
var retryAfter = RetryAfterOf(response);
try
{
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
return new ApiException(status, error.Code, error.Message);
return new ApiException(status, error.Code, error.Message) { RetryAfter = retryAfter };
}
catch (JsonException)
{
// réponse d'erreur non JSON (nginx, proxy…)
}
return status switch
var (code, message) = status switch
{
401 => new ApiException(status, ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
403 => new ApiException(status, ApiErrorCodes.Forbidden, "Action non autorisée."),
404 => new ApiException(status, ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
413 => new ApiException(status, ApiErrorCodes.InvalidRequest, "Requête trop volumineuse."),
429 => new ApiException(status, ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
>= 500 => new ApiException(status, ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
_ => new ApiException(status, ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
401 => (ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
403 => (ApiErrorCodes.Forbidden, "Action non autorisée."),
404 => (ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
413 => (ApiErrorCodes.InvalidRequest,
"Requête trop volumineuse pour le serveur (envoi de fichiers : vérifiez nginx-publications.conf sur le VPS)."),
429 => (ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
>= 500 => (ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
_ => (ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
};
return new ApiException(status, code, message) { RetryAfter = retryAfter };
}
/// <summary>En-tête Retry-After (en secondes ou en date) ; absent ou incohérent : null.</summary>
private static TimeSpan? RetryAfterOf(HttpResponseMessage response)
{
var header = response.Headers.RetryAfter;
var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null);
return delay is { } value && value >= TimeSpan.Zero ? value : null;
}
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
+34
View File
@@ -28,6 +28,9 @@ public static class ApiRoutes
public const string Admins = "v1/admins";
public const string Invitations = "v1/admins/invitations";
/// <summary>Compte de l'administrateur connecté (ses appareils, ses invitations « nouvel appareil »).</summary>
public const string MyAccount = "v1/admins/me";
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
@@ -48,4 +51,35 @@ public static class ApiRoutes
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
// ------------------------------------------------------------------ Signature du contenu (phase 7)
/// <summary>État des signatures (trousseau publié, manifests, catalogue) : propriétaires.</summary>
public const string Signing = "v1/signing";
/// <summary>Publication d'un trousseau signé par la clé racine : propriétaires.</summary>
public const string Keyring = "v1/keyring";
public const string ManifestSignaturePattern = "v1/games/{gameId}/signature";
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
public const string PublicationManifestPattern = "v1/publications/{id}/manifest";
public const string PublicationArchivePattern = "v1/publications/{id}/archive";
public const string PublicationObjectsPattern = "v1/publications/{id}/objects";
public const string PublicationObjectPattern = "v1/publications/{id}/objects/{sha256}";
public const string PublicationCommitPattern = "v1/publications/{id}/commit";
/// <summary>Préfixe des routes d'envoi (limite de taille propre, voir nginx-publications.conf).</summary>
public const string PublicationsPrefix = "v1/publications/";
public static string PublicationStart(string gameId) => $"v1/games/{gameId}/publications";
public static string Publication(string id) => $"v1/publications/{id}";
public static string PublicationManifest(string id, long offset) => $"v1/publications/{id}/manifest?offset={offset}";
public static string PublicationArchive(string id, long offset) => $"v1/publications/{id}/archive?offset={offset}";
public static string PublicationObjects(string id) => $"v1/publications/{id}/objects";
public static string PublicationObject(string id, string sha256, long offset) => $"v1/publications/{id}/objects/{sha256}?offset={offset}";
public static string PublicationCommit(string id) => $"v1/publications/{id}/commit";
}
@@ -1,3 +1,5 @@
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
@@ -23,6 +25,12 @@ public sealed record CatalogDocument
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Signature de l'administrateur qui a publié (phase 7) : couvre tout sauf la révision et la date
/// (<see cref="ContentSigning.CatalogDigest"/>). Ignorée par les launchers antérieurs.
/// </summary>
public ContentSignature? Signature { get; init; }
}
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
@@ -12,6 +12,12 @@ public sealed record AdminKeySummary
/// </summary>
public string? Fingerprint { get; init; }
/// <summary>
/// Clé publique (SPKI, Base64) des clés ACTIVES seulement (trousseau de la phase 7). Absente pour une clé en
/// attente (on pourrait en déduire l'empreinte) ou révoquée.
/// </summary>
public string? PublicKey { get; init; }
public DateTimeOffset AddedAt { get; init; }
public DateTimeOffset? LastUsedAt { get; init; }
}
@@ -26,6 +32,13 @@ public sealed record AdminSummary
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
}
/// <summary>Compte de l'administrateur connecté : ses appareils (clés) et les invitations « nouvel appareil » en cours.</summary>
public sealed record MyAccount
{
public AdminSummary Admin { get; init; } = new();
public IReadOnlyList<InvitationSummary> Invitations { get; init; } = Array.Empty<InvitationSummary>();
}
public sealed record CreateInvitationRequest
{
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
@@ -46,6 +59,10 @@ public sealed record InvitationSummary
public Guid? ForAdminId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
/// <summary>Administrateur qui a créé l'invitation (null : auteur inconnu).</summary>
public Guid? CreatedById { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
@@ -59,4 +59,13 @@ public static class ApiErrorCodes
/// <summary>Fonction non configurée sur le serveur.</summary>
public const string NotConfigured = "not_configured";
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
public const string OffsetMismatch = "offset_mismatch";
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
public const string PublicationInProgress = "publication_in_progress";
/// <summary>Espace disque insuffisant sur le serveur.</summary>
public const string InsufficientStorage = "insufficient_storage";
}
@@ -0,0 +1,207 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Publishing;
/// <summary>Ce que contient un manifest.json contrôlé.</summary>
/// <param name="Objects">Objets à servir (empreinte en minuscules → taille), une fois chacun.</param>
public sealed record ManifestSummary(
string GameId,
string Version,
string Executable,
string ArchiveName,
long ArchiveSize,
string ArchiveSha256,
IReadOnlyDictionary<string, long> Objects,
int FileCount,
long TotalSize,
string? NotesTitle);
/// <summary>
/// Contrôle d'un manifest.json produit par ManifestBuilder, AVANT de le publier : par le launcher
/// (dossier choisi) et par le serveur (manifest reçu). Mêmes règles des deux côtés ; le serveur ne fait
/// confiance à rien de ce qu'il reçoit.
/// • identifiant de jeu et version attendus ;
/// • archive : simple nom de fichier .zip (jamais un chemin), taille et empreinte valides ;
/// • objets dans « objects/ » (seul dossier écrit par la publication) ;
/// • chemins des fichiers relatifs et sûrs (ni « .. », ni chemin absolu, ni « : »), sans doublon ;
/// • exécutable présent parmi les fichiers ; tailles et nombre de fichiers bornés.
/// </summary>
public static partial class ManifestCheck
{
[GeneratedRegex(@"\A[A-Za-z0-9._+\-]{1,64}\z")]
private static partial Regex VersionRegex();
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}\.zip\z", RegexOptions.IgnoreCase)]
private static partial Regex ArchiveNameRegex();
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
public static bool IsValidVersion(string? version) => version is not null && VersionRegex().IsMatch(version);
/// <summary>Contrôle ; null (et <paramref name="errors"/> rempli) si le manifest n'est pas publiable.</summary>
public static ManifestSummary? Check(ReadOnlySpan<byte> json, string expectedGameId, string? expectedVersion, List<string> errors)
{
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
if (json.Length > PublishingLimits.MaxManifestBytes)
{
errors.Add("Manifest trop volumineux.");
return null;
}
JsonDocument document;
try
{
document = JsonDocument.Parse(json.ToArray(), ReadOptions);
}
catch (JsonException ex)
{
errors.Add($"Manifest illisible : {ex.Message}");
return null;
}
using (document)
{
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
errors.Add("Manifest illisible : objet JSON attendu.");
return null;
}
if (Get(root, "formatVersion") is { } format && (format.ValueKind != JsonValueKind.Number || format.GetDouble() != 1))
errors.Add("Format de manifest non pris en charge (formatVersion 1 attendu).");
var gameId = Text(root, "gameId");
if (!string.Equals(gameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
errors.Add($"Ce manifest est celui du jeu « {gameId} », pas de « {expectedGameId} ».");
var version = Text(root, "version");
if (!IsValidVersion(version))
errors.Add($"Version « {version} » invalide (lettres, chiffres, « . », « _ », « + », « - »).");
else if (expectedVersion is not null && !string.Equals(version, expectedVersion, StringComparison.Ordinal))
errors.Add($"Le manifest est celui de la version {version}, pas {expectedVersion}.");
var objectsBase = Text(root, "objectsBaseUrl");
if (!string.IsNullOrEmpty(objectsBase) && objectsBase is not ("objects/" or "objects"))
errors.Add($"« objectsBaseUrl » doit être « objects/ » (manifest produit par ManifestBuilder), pas « {objectsBase} ».");
// Archive (installation initiale)
string archiveName = "", archiveSha = "";
long archiveSize = 0;
if (Get(root, "archive") is { ValueKind: JsonValueKind.Object } archive)
{
archiveName = Text(archive, "url") ?? "";
archiveSha = (Text(archive, "sha256") ?? "").ToLowerInvariant();
archiveSize = Number(archive, "size");
if (!ArchiveNameRegex().IsMatch(archiveName))
errors.Add($"Archive « {archiveName} » : simple nom de fichier .zip attendu (à côté du manifest).");
if (!PublishingLimits.IsValidSha256(archiveSha)) errors.Add("Archive : empreinte SHA-256 invalide.");
if (archiveSize <= 0 || archiveSize > PublishingLimits.MaxArchiveBytes) errors.Add("Archive : taille invalide.");
}
else
{
errors.Add("Archive absente du manifest.");
}
// Fichiers du jeu
var objects = new Dictionary<string, long>(StringComparer.Ordinal);
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
long totalSize = 0;
var fileCount = 0;
if (Get(root, "files") is { ValueKind: JsonValueKind.Array } files)
{
fileCount = files.GetArrayLength();
if (fileCount == 0) errors.Add("Aucun fichier dans le manifest.");
if (fileCount > PublishingLimits.MaxFiles) errors.Add($"Trop de fichiers ({fileCount} ; {PublishingLimits.MaxFiles} au plus).");
else
{
var index = 0;
foreach (var file in files.EnumerateArray())
{
index++;
if (errors.Count > 20) break;
if (file.ValueKind != JsonValueKind.Object)
{
errors.Add($"Fichier n° {index} invalide.");
continue;
}
var path = Text(file, "path") ?? "";
var sha = (Text(file, "sha256") ?? "").ToLowerInvariant();
var size = Number(file, "size");
if (!IsSafeRelativePath(path)) errors.Add($"Chemin non autorisé : « {path} ».");
else if (!paths.Add(path.Replace('\\', '/'))) errors.Add($"Fichier en double : « {path} ».");
if (!PublishingLimits.IsValidSha256(sha)) errors.Add($"« {path} » : empreinte SHA-256 invalide.");
else if (size < 0 || size > PublishingLimits.MaxObjectBytes) errors.Add($"« {path} » : taille invalide.");
else if (objects.TryGetValue(sha, out var known) && known != size)
errors.Add($"« {path} » : même empreinte qu'un autre fichier mais taille différente.");
else
{
objects[sha] = size;
totalSize += size;
}
}
}
}
else
{
errors.Add("Liste des fichiers absente du manifest.");
}
var executable = (Text(root, "executable") ?? "").Replace('\\', '/');
if (string.IsNullOrWhiteSpace(executable)) errors.Add("Exécutable absent du manifest.");
else if (!paths.Contains(executable)) errors.Add($"L'exécutable « {executable} » ne fait pas partie des fichiers.");
if (Get(root, "changelog") is { } changelog && changelog.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null))
errors.Add("Notes de version (« changelog ») : liste attendue.");
if (errors.Count > 0) return null;
return new ManifestSummary(gameId!, version!, executable, archiveName, archiveSize, archiveSha, objects, fileCount, totalSize,
NotesTitle(root, version!));
}
}
/// <summary>Chemin relatif sûr (mêmes règles que le launcher pour l'installation).</summary>
public static bool IsSafeRelativePath(string path)
{
if (string.IsNullOrWhiteSpace(path) || path.Length > 1024 || path.Any(char.IsControl)) return false;
var p = path.Replace('\\', '/');
if (p.StartsWith('/') || p.Contains(':')) return false;
return p.Split('/').All(s => s.Length > 0 && s != "." && s != "..");
}
/// <summary>Chemin d'un objet sous le dossier du manifest : « objects/ab/abcdef….bin ».</summary>
public static string ObjectPath(string sha256)
{
var h = sha256.ToLowerInvariant();
return $"objects/{h[..2]}/{h}.bin";
}
private static string? NotesTitle(JsonElement root, string version)
{
if (Get(root, "changelog") is not { ValueKind: JsonValueKind.Array } changelog) return null;
foreach (var entry in changelog.EnumerateArray())
if (entry.ValueKind == JsonValueKind.Object && Text(entry, "version") == version)
return Text(entry, "title");
return null;
}
private static JsonElement? Get(JsonElement element, string name)
{
foreach (var property in element.EnumerateObject())
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
return property.Value;
return null;
}
private static string? Text(JsonElement element, string name) =>
Get(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null;
private static long Number(JsonElement element, string name) =>
Get(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : -1;
}
@@ -0,0 +1,160 @@
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Publishing;
/// <summary>
/// Bornes de la publication d'une version depuis le menu d'administration. Les requêtes d'envoi
/// (morceaux de fichiers) sont les seules à dépasser 64 Ko : elles ont leur propre limite, côté
/// serveur et côté nginx (server/deploy/nginx-publications.conf).
/// </summary>
public static partial class PublishingLimits
{
/// <summary>Taille d'un morceau envoyé par le launcher.</summary>
public const int ChunkBytes = 4 * 1024 * 1024;
/// <summary>Taille maximale d'un corps de requête d'envoi accepté par le serveur (morceau ou lot d'objets).</summary>
public const long MaxUploadRequestBytes = 8L * 1024 * 1024 + 64 * 1024;
/// <summary>Objets de cette taille au plus : envoyés par lots ; au-delà, en morceaux.</summary>
public const int BatchObjectMaxBytes = 2 * 1024 * 1024;
public const int MaxObjectsPerBatch = 500;
public const int MaxFiles = 10_000;
public const long MaxManifestBytes = 64L * 1024 * 1024;
public const long MaxObjectBytes = 32L * 1024 * 1024 * 1024;
public const long MaxArchiveBytes = 128L * 1024 * 1024 * 1024;
/// <summary>Publication abandonnée (aucun envoi) au-delà de cette durée : supprimée par le serveur.</summary>
public static readonly TimeSpan IdleExpiry = TimeSpan.FromHours(48);
/// <summary>Publication d'un autre administrateur sans envoi depuis cette durée : peut être remplacée.</summary>
public static readonly TimeSpan TakeoverAfter = TimeSpan.FromMinutes(30);
/// <summary>Type du corps d'un lot d'objets (voir <see cref="ObjectBatch"/>).</summary>
public const string ObjectBatchContentType = "application/vnd.gamelauncher.objects";
public const string BinaryContentType = "application/octet-stream";
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
private static partial Regex PublicationIdRegex();
[GeneratedRegex(@"\A[0-9a-fA-F]{64}\z")]
private static partial Regex Sha256Regex();
public static bool IsValidPublicationId(string? id) => id is not null && PublicationIdRegex().IsMatch(id);
public static bool IsValidSha256(string? value) => value is not null && Sha256Regex().IsMatch(value);
}
/// <summary>Début (ou reprise) de la publication d'une version : le manifest est envoyé ensuite.</summary>
public sealed record PublicationStartRequest
{
public string Version { get; init; } = "";
/// <summary>Empreinte du manifest.json à publier : même manifest = reprise d'un envoi interrompu.</summary>
public string ManifestSha256 { get; init; } = "";
public long ManifestSize { get; init; }
}
/// <summary>État d'une publication en cours (ce qui reste à envoyer).</summary>
public sealed record PublicationState
{
public string PublicationId { get; init; } = "";
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
/// <summary>Version en ligne au début de la publication (vide : aucune).</summary>
public string PublishedVersion { get; init; } = "";
/// <summary>Vrai si un envoi interrompu de ce même manifest est repris.</summary>
public bool Resumed { get; init; }
public long ManifestSize { get; init; }
public long ManifestReceived { get; init; }
/// <summary>Manifest reçu et contrôlé : la liste des objets à envoyer est connue.</summary>
public bool ManifestAccepted { get; init; }
/// <summary>Fichiers (objets) du manifest ; ceux déjà présents sur le serveur ne sont pas renvoyés.</summary>
public int ObjectCount { get; init; }
public int ObjectsOnServer { get; init; }
/// <summary>Empreintes des objets restant à envoyer.</summary>
public List<string> PendingObjects { get; init; } = new();
/// <summary>Objets envoyés en partie (empreinte → octets reçus) : l'envoi reprend à cet endroit.</summary>
public Dictionary<string, long> PartialObjects { get; init; } = new();
public string? ArchiveName { get; init; }
public long ArchiveSize { get; init; }
public long ArchiveReceived { get; init; }
public bool ArchiveComplete { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Réponse à l'envoi d'un morceau (ou d'un lot d'objets).</summary>
public sealed record ChunkResult
{
/// <summary>Octets reçus pour ce fichier (ou nombre d'objets enregistrés pour un lot).</summary>
public long Received { get; init; }
/// <summary>Fichier complet et vérifié (SHA-256).</summary>
public bool Complete { get; init; }
}
/// <summary>Version publiée.</summary>
public sealed record PublicationResult
{
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string PreviousVersion { get; init; } = "";
public string ManifestSha256 { get; init; } = "";
public int ObjectsAdded { get; init; }
public bool ArchiveAdded { get; init; }
}
/// <summary>
/// Lot d'objets dans un seul corps de requête (petits fichiers : une requête pour des centaines de
/// fichiers) : pour chaque objet, empreinte SHA-256 (32 octets bruts), taille (8 octets, gros-boutiste)
/// puis contenu.
/// </summary>
public static class ObjectBatch
{
public const int HeaderBytes = 32 + 8;
public static byte[] Write(IReadOnlyList<(string Sha256, byte[] Data)> objects)
{
var total = objects.Sum(o => (long)HeaderBytes + o.Data.Length);
var buffer = new byte[total];
var position = 0;
foreach (var (sha, data) in objects)
{
Convert.FromHexString(sha).CopyTo(buffer, position);
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(buffer.AsSpan(position + 32, 8), data.Length);
data.CopyTo(buffer, position + HeaderBytes);
position += HeaderBytes + data.Length;
}
return buffer;
}
/// <summary>Lecture bornée ; <see cref="InvalidDataException"/> si le lot est mal formé.</summary>
public static List<(string Sha256, ReadOnlyMemory<byte> Data)> Read(ReadOnlyMemory<byte> body, int maxObjects)
{
var result = new List<(string, ReadOnlyMemory<byte>)>();
var position = 0;
while (position < body.Length)
{
if (result.Count >= maxObjects) throw new InvalidDataException($"Lot trop grand ({maxObjects} objets au plus).");
if (body.Length - position < HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
var span = body.Span;
var sha = Convert.ToHexString(span.Slice(position, 32)).ToLowerInvariant();
var length = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(span.Slice(position + 32, 8));
if (length < 0 || length > body.Length - position - HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
result.Add((sha, body.Slice(position + HeaderBytes, (int)length)));
position += HeaderBytes + (int)length;
}
return result;
}
}
@@ -38,6 +38,28 @@ public static class SignedMessages
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
/// <summary>
/// Signature d'un contenu servi aux joueurs (manifest d'un jeu, catalogue) par la clé d'un administrateur
/// (phase 7). En-tête distinct : une signature de contenu ne peut pas servir de signature de requête,
/// et inversement.
/// </summary>
public const string ContentHeader = "GameLauncher/Content/v1";
/// <summary>Signature du trousseau (clés autorisées à signer le contenu) par la clé racine du launcher.</summary>
public const string KeyringHeader = "GameLauncher/Keyring/v1";
/// <param name="kind">« manifest » ou « catalog ».</param>
/// <param name="id">Identifiant du jeu (minuscules) ou « catalog ».</param>
/// <param name="digest">SHA-256 (hexadécimal minuscule) du contenu canonique.</param>
public static byte[] Content(string kind, string id, long signedAt, string keyId, string digest) =>
Build(ContentHeader,
("kind", kind), ("id", id), ("signed-at", signedAt.ToString(System.Globalization.CultureInfo.InvariantCulture)),
("key", keyId), ("digest", digest));
/// <param name="digest">SHA-256 (hexadécimal minuscule) du trousseau canonique (sans sa signature).</param>
public static byte[] Keyring(string rootKeyId, string digest) =>
Build(KeyringHeader, ("root", rootKeyId), ("digest", digest));
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
public static string HashInvitationCode(string normalizedCode) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
@@ -0,0 +1,242 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Signing;
/// <summary>
/// Signature d'un contenu servi aux joueurs (propriété « signature » du manifest.json d'un jeu ou du
/// catalog.json), produite par la clé d'un administrateur. Les anciens launchers ignorent cette propriété.
/// </summary>
public sealed record ContentSignature
{
/// <summary>Identifiant de la clé qui a signé (16 caractères hexadécimaux).</summary>
public string KeyId { get; init; } = "";
/// <summary>Date de la signature (heure du serveur, à la seconde) : sert aussi à refuser un contenu plus ancien que le dernier vu.</summary>
public DateTimeOffset SignedAt { get; init; }
/// <summary>Signature ECDSA P-256 / SHA-256 (64 octets, IEEE P1363) en Base64.</summary>
public string Value { get; init; } = "";
}
/// <summary>Types de contenu signés et autorisations (« scopes ») correspondantes dans le trousseau.</summary>
public static class ContentKinds
{
public const string Manifest = "manifest";
public const string Catalog = "catalog";
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
public const string CatalogId = "catalog";
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
public static string ScopeOf(string kind) => kind == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
}
/// <summary>Autorisations d'une clé du trousseau.</summary>
public static class SigningScopes
{
/// <summary>Signer le manifest d'un jeu (ce qui est installé et exécuté) : permission releases.publish.</summary>
public const string Releases = "releases";
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
public const string Catalog = "catalog";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog };
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
{
var set = permissions.ToHashSet(StringComparer.Ordinal);
var scopes = new List<string>();
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
return scopes;
}
public static string DisplayName(string scope) => scope switch
{
Releases => "versions du jeu",
Catalog => "catalogue",
_ => scope
};
}
/// <summary>
/// Partie d'un manifest couverte par la signature : tout ce qui décide de ce qui est installé et exécuté
/// (jeu, version, exécutable, arguments, archive, fichiers et leurs empreintes). Les notes de version et la
/// date ne le sont pas : elles restent modifiables en ligne (texte borné, jamais exécuté).
/// </summary>
public sealed record ManifestContent
{
public int FormatVersion { get; init; } = 1;
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string Executable { get; init; } = "";
public string? LaunchArguments { get; init; }
public ManifestContentArchive Archive { get; init; } = new();
public string? ObjectsBaseUrl { get; init; }
public List<ManifestContentFile> Files { get; init; } = new();
/// <summary>Lit un manifest.json (mêmes noms de propriétés que le launcher, casse indifférente).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static ManifestContent Parse(ReadOnlySpan<byte> json)
{
if (json.StartsWith(Utf8Bom)) json = json[Utf8Bom.Length..];
return JsonSerializer.Deserialize<ManifestContent>(json, ReadOptions) ?? throw new JsonException("manifest vide");
}
private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 32
};
}
public sealed record ManifestContentArchive
{
public string Url { get; init; } = "";
public long Size { get; init; }
public string Sha256 { get; init; } = "";
}
public sealed record ManifestContentFile
{
public string Path { get; init; } = "";
public long Size { get; init; }
public string Sha256 { get; init; } = "";
}
/// <summary>Résultat d'une vérification de signature de contenu.</summary>
public sealed record ContentCheck(bool Valid, string Reason, KeyringKey? Key)
{
public static ContentCheck Fail(string reason) => new(false, reason, null);
}
/// <summary>
/// Condensés canoniques et signatures du contenu. Le même code est utilisé par le menu d'administration
/// (qui signe), le serveur (qui contrôle avant d'écrire) et le launcher des joueurs (qui vérifie) : chacun
/// calcule le condensé à partir de ce qu'il a lui-même lu.
/// </summary>
public static class ContentSigning
{
/// <summary>Écart toléré par le serveur entre la date de signature et son horloge.</summary>
public static readonly TimeSpan MaxSignedAtSkew = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions Canonical = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Condensé du contenu signé d'un manifest (fichiers triés, empreintes en minuscules ; valeurs absentes = vides).</summary>
public static string ManifestDigest(ManifestContent manifest)
{
var archive = manifest.Archive ?? new ManifestContentArchive();
var canonical = new ManifestContent
{
FormatVersion = manifest.FormatVersion,
GameId = (manifest.GameId ?? "").ToLowerInvariant(),
Version = manifest.Version ?? "",
Executable = (manifest.Executable ?? "").Replace('\\', '/'),
LaunchArguments = string.IsNullOrEmpty(manifest.LaunchArguments) ? null : manifest.LaunchArguments,
Archive = new ManifestContentArchive
{
Url = archive.Url ?? "",
Size = archive.Size,
Sha256 = (archive.Sha256 ?? "").ToLowerInvariant()
},
ObjectsBaseUrl = string.IsNullOrWhiteSpace(manifest.ObjectsBaseUrl) ? "objects/" : manifest.ObjectsBaseUrl,
Files = (manifest.Files ?? new List<ManifestContentFile>())
.Where(f => f is not null)
.Select(f => new ManifestContentFile { Path = f.Path ?? "", Size = f.Size, Sha256 = (f.Sha256 ?? "").ToLowerInvariant() })
.OrderBy(f => f.Path, StringComparer.Ordinal)
.ThenBy(f => f.Sha256, StringComparer.Ordinal)
.ToList()
};
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>Condensé du contenu signé d'un manifest.json (octets bruts).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static string ManifestDigest(ReadOnlySpan<byte> manifestJson) => ManifestDigest(ManifestContent.Parse(manifestJson));
/// <summary>
/// Condensé du contenu signé d'un catalogue : tout sauf la révision, la date et la signature (posées par le
/// serveur) ; jeux triés par identifiant, tels qu'écrits (sans changer la casse : deux identifiants qui ne
/// diffèrent que par la casse donneraient deux entrées distinctes, voir <see cref="HasDuplicateGames"/>).
/// </summary>
public static string CatalogDigest(CatalogDocument catalog)
{
var games = new SortedDictionary<string, CatalogGame>(StringComparer.Ordinal);
foreach (var (id, game) in catalog.Games ?? new Dictionary<string, CatalogGame>())
games[id] = game;
var canonical = new CanonicalCatalog(catalog.Format, catalog.Version, catalog.Launcher, games);
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>
/// Identifiants de jeux en double à la casse près : le launcher n'en garderait qu'un, différent de celui
/// couvert par la signature. Un tel catalogue est refusé par la vérification.
/// </summary>
public static bool HasDuplicateGames(CatalogDocument catalog) =>
(catalog.Games?.Keys ?? Enumerable.Empty<string>()).GroupBy(k => k, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
public static string SignedId(string kind, string id) => kind == ContentKinds.Catalog ? ContentKinds.CatalogId : id.ToLowerInvariant();
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
/// <summary>Message à signer par la clé de l'administrateur.</summary>
public static byte[] Message(string kind, string id, DateTimeOffset signedAt, string keyId, string digest) =>
SignedMessages.Content(kind, SignedId(kind, id), signedAt.ToUnixTimeSeconds(), keyId, digest);
/// <summary>Vérifie une signature avec une clé publique donnée (le serveur : la clé de l'administrateur qui envoie).</summary>
public static bool VerifyWithKey(string kind, string id, string digest, ContentSignature signature, ReadOnlySpan<byte> publicKey)
{
if (!AdminKeys.IsValidKeyId(signature.KeyId) || signature.SignedAt == default) return false;
if (AdminKeys.DecodeSignature(signature.Value) is not { } value) return false;
if (!string.Equals(AdminKeys.ComputeKeyId(publicKey), signature.KeyId, StringComparison.Ordinal)) return false;
return AdminKeys.Verify(publicKey, Message(kind, id, signature.SignedAt, signature.KeyId, digest), value);
}
/// <summary>
/// Vérifie une signature avec le trousseau (le launcher des joueurs) : clé présente, autorisée pour ce type
/// de contenu, signature valide sur le condensé calculé localement.
/// </summary>
public static ContentCheck Verify(string kind, string id, string digest, ContentSignature? signature, KeyringDocument keyring)
{
if (signature is null) return ContentCheck.Fail("contenu non signé");
if (!AdminKeys.IsValidKeyId(signature.KeyId)) return ContentCheck.Fail("signature mal formée");
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.Ordinal));
if (key is null) return ContentCheck.Fail($"signé par une clé absente du trousseau ({signature.KeyId})");
if (key.Scopes is null || !key.Scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal))
return ContentCheck.Fail($"la clé {signature.KeyId} ({key.Name}) n'est pas autorisée à signer ce contenu");
byte[] publicKey;
try
{
publicKey = Convert.FromBase64String(key.PublicKey);
}
catch (FormatException)
{
return ContentCheck.Fail("clé du trousseau illisible");
}
return VerifyWithKey(kind, id, digest, signature, publicKey)
? new ContentCheck(true, $"signé par {key.Name} le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC", key)
: ContentCheck.Fail("SIGNATURE INVALIDE (contenu modifié après signature, ou signature d'un autre contenu)");
}
internal static string Sha256(ReadOnlySpan<byte> data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}
@@ -0,0 +1,180 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Signing;
/// <summary>
/// Trousseau (keyring.json) : clés d'administrateurs autorisées à signer le contenu servi aux joueurs, signé
/// par la clé RACINE (celle des mises à jour du launcher, dont la clé publique est intégrée à l'exe). La clé
/// racine reste hors ligne (LauncherPublisher, « sign-keyring.bat ») : un serveur compromis ne peut ni
/// ajouter une clé, ni modifier le trousseau sans que les launchers le refusent.
/// </summary>
public sealed record KeyringDocument
{
public const string FormatName = "gamelauncher-keyring";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Numéro croissant : un launcher refuse un trousseau plus ancien que le dernier qu'il a vu.</summary>
public long Sequence { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public List<KeyringKey> Keys { get; init; } = new();
/// <summary>Signature de la clé racine (absente dans une demande de trousseau, avant signature).</summary>
public KeyringSignature? Signature { get; init; }
}
public sealed record KeyringKey
{
public string KeyId { get; init; } = "";
/// <summary>Clé publique ECDSA P-256 (SPKI, Base64).</summary>
public string PublicKey { get; init; } = "";
/// <summary>Administrateur et appareil (affichage, journal du launcher).</summary>
public string Name { get; init; } = "";
/// <summary>Ce que la clé peut signer (<see cref="SigningScopes"/>).</summary>
public List<string> Scopes { get; init; } = new();
}
public sealed record KeyringSignature
{
/// <summary>Identifiant de la clé racine (16 caractères hexadécimaux, comme pour les mises à jour du launcher).</summary>
public string RootKeyId { get; init; } = "";
public string Value { get; init; } = "";
}
/// <summary>Contrôles, condensé et signature du trousseau (même code partout).</summary>
public static class Keyring
{
/// <summary>Taille maximale de keyring.json.</summary>
public const int MaxBytes = 60 * 1024; // envoyé à l'API : sous la limite de 64 Ko des requêtes
public const int MaxKeys = 100;
public const int MaxNameLength = 120;
private static readonly JsonSerializerOptions Canonical = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Lit un trousseau (null : JSON vide).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static KeyringDocument? Parse(ReadOnlySpan<byte> json)
{
if (json.Length > MaxBytes) throw new JsonException($"trousseau trop volumineux (au plus {MaxBytes / 1024} Ko)");
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
return JsonSerializer.Deserialize<KeyringDocument>(json, ApiJson.Options);
}
public static string Serialize(KeyringDocument keyring) =>
JsonSerializer.Serialize(keyring, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true });
/// <summary>Condensé du trousseau sans sa signature (clés triées, autorisations triées).</summary>
public static string Digest(KeyringDocument keyring)
{
var canonical = keyring with
{
Signature = null,
IssuedAt = ContentSigning.Truncate(keyring.IssuedAt),
Keys = (keyring.Keys ?? new List<KeyringKey>())
.Select(k => k with { Scopes = (k.Scopes ?? new List<string>()).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal).ToList() })
.OrderBy(k => k.KeyId, StringComparer.Ordinal)
.ToList()
};
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
}
/// <summary>Contrôle de structure (sans la signature racine). Les erreurs sont ajoutées à <paramref name="errors"/>.</summary>
public static bool Validate(KeyringDocument keyring, List<string> errors)
{
var count = errors.Count;
if (keyring.Format != KeyringDocument.FormatName || keyring.Version != KeyringDocument.CurrentVersion)
errors.Add($"Format de trousseau non pris en charge ({keyring.Format} v{keyring.Version}).");
if (keyring.Sequence < 1) errors.Add("Numéro du trousseau invalide (1 au moins).");
if (keyring.IssuedAt == default) errors.Add("Date du trousseau absente.");
if (keyring.Keys is null || keyring.Keys.Any(k => k is null || k.Scopes is null || k.PublicKey is null || k.Name is null))
{
errors.Add("Trousseau incomplet (clés, clé publique, nom ou autorisations absents).");
return false;
}
if (keyring.Keys.Count > MaxKeys) errors.Add($"Trop de clés dans le trousseau (au plus {MaxKeys}).");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in keyring.Keys)
{
var label = AdminKeys.IsValidKeyId(key.KeyId) ? key.KeyId : "(identifiant invalide)";
if (!AdminKeys.IsValidKeyId(key.KeyId)) errors.Add("Clé du trousseau : identifiant invalide.");
else if (!seen.Add(key.KeyId)) errors.Add($"Clé {label} en double.");
try
{
var spki = AdminKeys.ParsePublicKey(key.PublicKey);
if (!string.Equals(AdminKeys.ComputeKeyId(spki), key.KeyId, StringComparison.Ordinal))
errors.Add($"Clé {label} : l'identifiant ne correspond pas à la clé publique.");
}
catch (CryptographicException)
{
errors.Add($"Clé {label} : clé publique illisible ou d'un autre type que ECDSA P-256.");
}
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.All.Contains(s, StringComparer.Ordinal)))
errors.Add($"Clé {label} : autorisations absentes ou inconnues.");
}
return errors.Count == count;
}
/// <summary>
/// Nom affichable sans tromperie : ni caractères de contrôle, ni caractères de mise en forme invisibles
/// (inversion du sens d'écriture…), qui pourraient maquiller un nom dans la console ou les fenêtres.
/// </summary>
public static bool IsDisplaySafe(string name) =>
!name.Any(c => char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
or System.Globalization.UnicodeCategory.LineSeparator
or System.Globalization.UnicodeCategory.ParagraphSeparator);
/// <summary>Signe le trousseau avec la clé racine (LauncherPublisher).</summary>
public static KeyringDocument Sign(KeyringDocument keyring, ECDsa rootKey)
{
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
var unsigned = keyring with { Signature = null, IssuedAt = ContentSigning.Truncate(keyring.IssuedAt) };
var signature = AdminKeys.Sign(rootKey, SignedMessages.Keyring(rootKeyId, Digest(unsigned)));
return unsigned with { Signature = new KeyringSignature { RootKeyId = rootKeyId, Value = Convert.ToBase64String(signature) } };
}
/// <summary>
/// Vérifie la signature racine avec les clés publiques intégrées au launcher (celles de update-signing.pub).
/// </summary>
/// <param name="rootKeys">Identifiant et clé publique SPKI de chaque clé racine de confiance.</param>
public static bool VerifyRoot(KeyringDocument keyring, IEnumerable<(string KeyId, byte[] PublicKey)> rootKeys, out string error)
{
error = "";
if (keyring.Signature is not { } signature)
{
error = "trousseau non signé par la clé racine";
return false;
}
var root = rootKeys.FirstOrDefault(k => string.Equals(k.KeyId, signature.RootKeyId, StringComparison.OrdinalIgnoreCase));
if (root.PublicKey is null)
{
error = $"trousseau signé par une clé racine inconnue de ce launcher ({signature.RootKeyId})";
return false;
}
if (AdminKeys.DecodeSignature(signature.Value) is not { } value ||
!AdminKeys.Verify(root.PublicKey, SignedMessages.Keyring(signature.RootKeyId.ToLowerInvariant(), Digest(keyring)), value))
{
error = "SIGNATURE DU TROUSSEAU INVALIDE";
return false;
}
return true;
}
}
@@ -0,0 +1,66 @@
namespace GameLauncher.Contracts.Signing;
/// <summary>Mise en ligne d'une version : signature du manifest par l'administrateur qui publie.</summary>
public sealed record PublicationCommitRequest
{
/// <summary>Signature du contenu du manifest envoyé (<see cref="ContentSigning.ManifestDigest(ManifestContent)"/>).</summary>
public ContentSignature? Signature { get; init; }
}
/// <summary>Signature du manifest en ligne d'un jeu (avec le condensé calculé par le serveur).</summary>
public sealed record ManifestSignatureState
{
public string GameId { get; init; } = "";
/// <summary>Version en ligne (vide : aucun manifest publié).</summary>
public string Version { get; init; } = "";
/// <summary>Condensé du contenu signé de la version en ligne (vide : aucun manifest publié).</summary>
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
/// <summary>Administrateur et appareil de la clé qui a signé, si le serveur la connaît.</summary>
public string? SignerName { get; init; }
}
/// <summary>
/// Signature de la version en ligne (déjà publiée, par exemple avec WinSCP) à partir du manifest.json du dossier
/// de publication : le serveur ne l'accepte que si le contenu en ligne est identique.
/// </summary>
public sealed record ManifestSignRequest
{
/// <summary>Condensé calculé par le launcher à partir du manifest.json local.</summary>
public string Digest { get; init; } = "";
public ContentSignature Signature { get; init; } = new();
}
/// <summary>Signatures du contenu, pour la page « Signature » (propriétaires).</summary>
public sealed record SigningState
{
/// <summary>Trousseau publié (null : aucun).</summary>
public KeyringDocument? Keyring { get; init; }
/// <summary>Faux : publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou catalogue absent).</summary>
public bool KeyringPublishingEnabled { get; init; }
/// <summary>Adresse publique du trousseau (à mettre dans « keyringUrl » de launcher.json).</summary>
public string? KeyringPublicUrl { get; init; }
/// <summary>Vrai : le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require).</summary>
public bool RequireSignatures { get; init; }
public List<ManifestSignatureState> Manifests { get; init; } = new();
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
public CatalogSignatureState? Catalog { get; init; }
}
public sealed record CatalogSignatureState
{
public long Revision { get; init; }
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
}
+26 -12
View File
@@ -5,6 +5,7 @@ using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
@@ -27,14 +28,19 @@ public sealed class CatalogService
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl)
: this(http.GetBytesAsync, dataDirectory, catalogUrl) { }
private readonly ContentTrustService? _trust;
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl, ContentTrustService? trust = null)
: this(http.GetBytesAsync, dataDirectory, catalogUrl, trust) { }
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl)
/// <param name="trust">Vérification de la signature (phase 7) : en mode « require », un catalogue refusé est ignoré.</param>
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl,
ContentTrustService? trust = null)
{
_download = download;
_catalogUrl = catalogUrl;
_trust = trust;
Directory = Path.Combine(dataDirectory, FolderName);
CachePath = Path.Combine(Directory, "catalog.json");
ImagesDirectory = Path.Combine(Directory, "images");
@@ -44,13 +50,18 @@ public sealed class CatalogService
public string CachePath { get; }
public string ImagesDirectory { get; }
/// <summary>Catalogue du cache (null : absent ou illisible).</summary>
/// <summary>
/// Catalogue du cache (null : absent, illisible ou, en mode « require », non signé). Le cache contient le
/// catalogue tel que publié (signature comprise) : il est revérifié puis contrôlé à chaque lecture.
/// </summary>
public CatalogDocument? LoadCached()
{
try
{
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
return Parse(File.ReadAllBytes(CachePath), out _);
if (ParseRaw(File.ReadAllBytes(CachePath)) is not { } raw) return null;
if (_trust is not null && !_trust.VerifyCachedCatalog(raw)) return null;
return CatalogRules.Normalize(raw, _catalogUrl.Host, new List<string>());
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
{
@@ -68,12 +79,16 @@ public sealed class CatalogService
try
{
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
var catalog = Parse(bytes, out var errors);
if (catalog is null) return null;
if (ParseRaw(bytes) is not { } raw) return null;
// Signature vérifiée sur le catalogue tel que publié, avant tout contrôle des valeurs.
if (_trust is not null && !await _trust.VerifyCatalogAsync(raw, ct).ConfigureAwait(false)) return null;
var errors = new List<string>();
var catalog = CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
// Cache : le catalogue tel que publié (la signature reste vérifiable hors ligne).
System.IO.Directory.CreateDirectory(Directory);
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(catalog, ApiJson.Options));
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(raw, ApiJson.Options));
return catalog;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
@@ -88,10 +103,9 @@ public sealed class CatalogService
}
}
/// <summary>Lecture + contrôle (valeurs invalides retirées). Null si le format est inconnu.</summary>
private CatalogDocument? Parse(byte[] bytes, out List<string> errors)
/// <summary>Catalogue tel que publié (null : format non pris en charge).</summary>
private static CatalogDocument? ParseRaw(byte[] bytes)
{
errors = new List<string>();
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
?? throw new InvalidDataException("catalogue vide");
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
@@ -99,7 +113,7 @@ public sealed class CatalogService
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
return null;
}
return CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
return raw;
}
// ------------------------------------------------------------------ Images
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Models;
@@ -40,9 +41,28 @@ public sealed class GameManifest
public List<ChangelogEntry> Changelog { get; set; } = new();
/// <summary>
/// Signature de l'administrateur qui a mis la version en ligne (phase 7), vérifiée avec le trousseau
/// (<see cref="Trust.ContentTrustService"/>). Absente des manifests produits par ManifestBuilder.
/// </summary>
public ContentSignature? Signature { get; set; }
[JsonIgnore]
public long TotalSize => Files.Sum(f => f.Size);
/// <summary>Partie couverte par la signature (tout ce qui décide de ce qui est installé et exécuté).</summary>
public ManifestContent ToSignedContent() => new()
{
FormatVersion = FormatVersion,
GameId = GameId,
Version = Version,
Executable = Executable,
LaunchArguments = LaunchArguments,
Archive = new ManifestContentArchive { Url = Archive.Url, Size = Archive.Size, Sha256 = Archive.Sha256 },
ObjectsBaseUrl = ObjectsBaseUrl,
Files = Files.Select(f => new ManifestContentFile { Path = f.Path, Size = f.Size, Sha256 = f.Sha256 }).ToList()
};
/// <summary>URL d'où le manifest a été téléchargé (sert à résoudre les URL relatives).</summary>
[JsonIgnore]
public Uri? SourceUri { get; set; }
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
/// </summary>
public sealed class LauncherCatalog
{
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl" };
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\&lt;Publisher&gt;\GameLauncher).</summary>
public const string DefaultDataFolderName = "GameLauncher";
@@ -98,6 +98,34 @@ public sealed class LauncherCatalog
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? CatalogUrlError { get; private set; }
/// <summary>
/// Adresse du trousseau (keyring.json : clés autorisées à signer le contenu, signé par la clé racine du
/// launcher), ex. « https://launcher.lanisys.fr/catalog/keyring.json ». Facultatif. Comme <see cref="ApiUrl"/>,
/// le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
/// </summary>
public string? KeyringUrl { get; private set; }
/// <summary>Raison pour laquelle « keyringUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? KeyringUrlError { get; private set; }
/// <summary>
/// « contentSigning » : vérification des signatures du manifest et du catalogue. « warn » (défaut si
/// keyringUrl est renseignée) : un contenu non signé ou mal signé est signalé dans le journal mais utilisé ;
/// « require » : il est refusé (aucune installation ni mise à jour, catalogue ignoré) ; « off » : rien n'est vérifié.
/// Comme keyringUrl, lu uniquement dans le launcher.json intégré à l'exe.
/// </summary>
public Trust.ContentSigningMode ContentSigning { get; private set; }
/// <summary>Raison pour laquelle « contentSigning » a été ignorée ou est incohérente, sinon null.</summary>
public string? ContentSigningError { get; private set; }
/// <summary>
/// « keyringMinSequence » : numéro minimal du trousseau accepté (0 par défaut). Un trousseau plus ancien, même
/// signé par la clé racine, est refusé : à relever dans le launcher publié après le retrait d'une clé compromise,
/// pour qu'une installation neuve n'accepte pas un ancien trousseau qui la contenait encore. Intégré à l'exe.
/// </summary>
public long KeyringMinSequence { get; private set; }
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
{
@@ -290,7 +318,7 @@ public sealed class LauncherCatalog
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
CatalogUrlError = singleCatalogError,
DataFolderName = ReadDataFolder(root)
};
}.WithContentSigning(root);
}
if (gamesNode is not JsonArray array || array.Count == 0)
@@ -345,7 +373,37 @@ public sealed class LauncherCatalog
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
CatalogUrlError = catalogError,
DataFolderName = ReadDataFolder(root)
}.WithContentSigning(root);
}
/// <summary>« keyringUrl », « contentSigning » et « keyringMinSequence » (phase 7).</summary>
private LauncherCatalog WithContentSigning(JsonObject root)
{
KeyringUrl = ReadTrustedUrl(root, "keyringUrl", "https://launcher.exemple.fr/catalog/keyring.json", out var keyringError);
KeyringUrlError = keyringError;
var text = Get(root, "contentSigning") is JsonValue value && value.TryGetValue<string>(out var s) ? s.Trim() : null;
var declared = Get(root, "contentSigning") is not null;
(ContentSigning, ContentSigningError) = text?.ToLowerInvariant() switch
{
"off" => (Trust.ContentSigningMode.Off, null),
"warn" => (Trust.ContentSigningMode.Warn, null),
"require" => (Trust.ContentSigningMode.Require, null),
_ when !declared => (KeyringUrl is null ? Trust.ContentSigningMode.Off : Trust.ContentSigningMode.Warn, null),
// Valeur invalide : prudence (vérification sans blocage), signalée dans le journal.
_ => (Trust.ContentSigningMode.Warn,
"launcher.json : contentSigning ignorée (valeurs possibles : \"off\", \"warn\", \"require\") : vérification sans blocage (« warn »).")
};
if (Get(root, "keyringMinSequence") is { } minNode)
{
if (minNode is JsonValue minValue && minValue.TryGetValue<long>(out var min) && min >= 0)
KeyringMinSequence = min;
else
ContentSigningError ??= "launcher.json : keyringMinSequence ignorée (nombre entier positif attendu).";
}
if (ContentSigning == Trust.ContentSigningMode.Require && KeyringUrl is null)
ContentSigningError = "launcher.json : contentSigning « require » sans keyringUrl valide : aucun manifest ni catalogue ne pourra être " +
"vérifié, donc aucune installation ni mise à jour ne sera possible.";
return this;
}
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
@@ -1,6 +1,7 @@
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Services;
@@ -9,11 +10,16 @@ public sealed class ManifestClient
{
private readonly HttpDownloader _http;
private readonly Uri _manifestUri;
private readonly ContentTrustService? _trust;
private readonly string? _gameId;
public ManifestClient(HttpDownloader http, Uri manifestUri)
/// <param name="trust">Vérification des signatures (phase 7), avec l'identifiant du jeu de launcher.json.</param>
public ManifestClient(HttpDownloader http, Uri manifestUri, ContentTrustService? trust = null, string? gameId = null)
{
_http = http;
_manifestUri = manifestUri;
_trust = trust;
_gameId = gameId;
}
public async Task<GameManifest> FetchAsync(CancellationToken ct = default)
@@ -32,6 +38,8 @@ public sealed class ManifestClient
manifest.SourceUri = _manifestUri;
manifest.Validate();
// Signature (phase 7) : refus en mode « require » (ContentSecurityException), sinon journal seulement.
if (_trust is not null && _gameId is not null) await _trust.VerifyManifestAsync(manifest, _gameId, ct).ConfigureAwait(false);
// Notes de version bornées et nettoyées : elles sont affichées telles quelles dans l'interface.
manifest.Changelog = ChangelogLimits.Normalize(manifest.Changelog);
Log.Info($"Manifest récupéré : version {manifest.Version}, {manifest.Files.Count} fichiers, {Format.Bytes(manifest.TotalSize)}.");
@@ -0,0 +1,322 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Trust;
/// <summary>Vérification des signatures du contenu (« contentSigning » de launcher.json).</summary>
public enum ContentSigningMode
{
/// <summary>Rien n'est vérifié (comportement antérieur à la phase 7).</summary>
Off,
/// <summary>Signatures vérifiées ; un problème est inscrit au journal, le contenu est utilisé quand même (mise en place).</summary>
Warn,
/// <summary>Un contenu non signé, mal signé ou plus ancien que le dernier vu est refusé.</summary>
Require
}
/// <summary>Contenu refusé (mode « require ») : signature absente ou invalide, trousseau indisponible, retour en arrière.</summary>
public sealed class ContentSecurityException : Exception
{
public ContentSecurityException(string message) : base(message) { }
}
/// <summary>
/// Confiance dans le contenu servi par le serveur (phase 7) :
/// • trousseau (keyring.json) téléchargé puis vérifié avec la ou les clés RACINE intégrées à l'exe (celles des
/// mises à jour du launcher), conservé en cache ; un trousseau plus ancien que le dernier vu est refusé ;
/// • manifest et catalogue : signature d'une clé du trousseau autorisée pour ce contenu, sur le condensé calculé
/// ici à partir de ce qui a été téléchargé ; un contenu signé plus tôt que le dernier accepté est refusé (un
/// serveur compromis ne peut pas resservir une ancienne version vulnérable) ;
/// • mode « warn » : problèmes inscrits au journal sans blocage ; « require » : contenu refusé.
/// État conservé dans le dossier des données du launcher (trust\content-trust.json).
/// </summary>
public sealed class ContentTrustService
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri? _keyringUrl;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
private readonly TimeProvider _time;
private readonly object _lock = new();
private Task<KeyringDocument?>? _keyringTask;
private bool _keyringFetched;
private DateTimeOffset _lastAttempt;
private TrustState? _state;
/// <param name="rootKeys">Clés racine de confiance (update-signing.pub intégré à l'exe).</param>
/// <param name="minKeyringSequence">
/// Numéro minimal du trousseau (« keyringMinSequence » de launcher.json) : un trousseau plus ancien, même signé par
/// la racine, est refusé. À relever après le retrait d'une clé compromise, pour les installations neuves.
/// </param>
/// <param name="time">Horloge (tests).</param>
public ContentTrustService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri? keyringUrl,
IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys, ContentSigningMode mode, long minKeyringSequence = 0,
TimeProvider? time = null)
{
_download = download;
_time = time ?? TimeProvider.System;
_keyringUrl = keyringUrl;
_rootKeys = rootKeys;
Mode = mode;
MinKeyringSequence = Math.Max(0, minKeyringSequence);
StatePath = Path.Combine(dataDirectory, "trust", "content-trust.json");
}
public ContentSigningMode Mode { get; }
public long MinKeyringSequence { get; }
public string StatePath { get; }
// ------------------------------------------------------------------ Trousseau
/// <summary>
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
/// </summary>
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
{
Task<KeyringDocument?> task;
lock (_lock)
{
var elapsed = _time.GetUtcNow() - _lastAttempt;
if (_keyringTask is null
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
{
_lastAttempt = _time.GetUtcNow();
_keyringTask = FetchKeyringAsync();
}
task = _keyringTask;
}
return await task.WaitAsync(ct).ConfigureAwait(false);
}
/// <summary>Dernier trousseau vérifié en cache (sans réseau).</summary>
public KeyringDocument? CachedKeyring => LoadState().Keyring;
private async Task<KeyringDocument?> FetchKeyringAsync()
{
var cached = LoadState().Keyring;
if (_keyringUrl is null || Mode == ContentSigningMode.Off) return cached;
try
{
using var timeout = new CancellationTokenSource(FetchTimeout);
var bytes = await _download(_keyringUrl, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0]);
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error);
if (keyring.Sequence < MinKeyringSequence)
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
lock (_lock)
{
_keyringFetched = true;
if (cached is not null)
{
if (keyring.Sequence < cached.Sequence)
{
Log.Error($"Trousseau n° {keyring.Sequence} refusé : plus ancien que le n° {cached.Sequence} déjà vu (retour en arrière).");
return cached;
}
if (keyring.Sequence == cached.Sequence && Keyring.Digest(keyring) != Keyring.Digest(cached))
{
Log.Error($"Trousseau n° {keyring.Sequence} refusé : différent de celui déjà vu sous le même numéro.");
return cached;
}
}
var state = LoadState();
if (cached is null || keyring.Sequence > cached.Sequence)
{
state.Keyring = keyring;
SaveState(state);
Log.Info($"Trousseau n° {keyring.Sequence} vérifié ({keyring.Keys.Count} clé(s)).");
}
return keyring;
}
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
return cached;
}
catch (Exception ex)
{
// Réseau, délai dépassé, réponse trop grande… : jamais d'exception vers l'appelant, nouvel essai plus tard.
Log.Warn($"Trousseau injoignable : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu est utilisé)."));
return cached;
}
}
// ------------------------------------------------------------------ Contenu
/// <summary>Vérifie le manifest d'un jeu (lève <see cref="ContentSecurityException"/> en mode « require »).</summary>
public Task VerifyManifestAsync(GameManifest manifest, string gameId, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return Task.CompletedTask;
return VerifyAsync(ContentKinds.Manifest, gameId, ContentSigning.ManifestDigest(manifest.ToSignedContent()), manifest.Signature,
$"Manifest de {gameId} (version {manifest.Version})", ct);
}
/// <summary>Vérifie le catalogue tel que téléchargé (avant tout contrôle des valeurs). Faux : à ignorer.</summary>
public async Task<bool> VerifyCatalogAsync(CatalogDocument catalog, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Catalogue (révision {catalog.Revision})";
try
{
if (ContentSigning.HasDuplicateGames(catalog))
return Report(what, "jeux en double (identifiants ne différant que par la casse)");
await VerifyAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, what, ct)
.ConfigureAwait(false);
return true;
}
catch (ContentSecurityException)
{
return false;
}
}
/// <summary>
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
/// L'antériorité n'est pas contrôlée ici (le cache est ce qui a déjà été accepté).
/// </summary>
public bool VerifyCachedCatalog(CatalogDocument catalog)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Catalogue en cache (révision {catalog.Revision})";
if (ContentSigning.HasDuplicateGames(catalog)) return Report(what, "jeux en double (identifiants ne différant que par la casse)");
var keyring = LoadState().Keyring;
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, keyring);
return check.Valid || Report(what, check.Reason);
}
/// <summary>Problème : refus en mode « require » (faux), avertissement en mode « warn » (vrai).</summary>
private bool Report(string what, string problem)
{
if (Mode == ContentSigningMode.Require)
{
Log.Error($"{what} : {problem}. Contenu refusé (contentSigning « require »).");
return false;
}
Log.Warn($"{what} : {problem}. Contenu utilisé quand même (contentSigning « warn »).");
return true;
}
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
{
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
string problem;
if (keyring is null)
{
problem = _keyringUrl is null ? "aucun trousseau configuré (keyringUrl)" : "trousseau indisponible (jamais téléchargé)";
}
else
{
var check = ContentSigning.Verify(kind, id, digest, signature, keyring);
if (!check.Valid)
{
problem = check.Reason;
}
else
{
var slot = $"{kind}:{ContentSigning.SignedId(kind, id)}";
lock (_lock)
{
var state = LoadState();
if (state.LastSigned.TryGetValue(slot, out var last) && signature!.SignedAt < last)
{
problem = $"signé le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC, plus ancien que le contenu déjà vu " +
$"(signé le {last:yyyy-MM-dd HH:mm} UTC) : retour en arrière refusé";
}
else
{
// Date enregistrée plafonnée : une signature datée loin dans le futur (horloge déréglée) ne doit
// pas faire refuser toutes les publications suivantes.
var ceiling = ContentSigning.Truncate(_time.GetUtcNow() + MaxFutureSignedAt);
var recorded = signature!.SignedAt > ceiling ? ceiling : signature.SignedAt;
if (recorded > last)
{
state.LastSigned[slot] = recorded;
SaveState(state);
}
Log.Info($"{what} : {check.Reason}.");
return;
}
}
}
}
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
}
// ------------------------------------------------------------------ État
private sealed class TrustState
{
public KeyringDocument? Keyring { get; set; }
public Dictionary<string, DateTimeOffset> LastSigned { get; set; } = new(StringComparer.Ordinal);
}
/// <summary>État en cache (le trousseau est revérifié à la lecture : un fichier modifié à la main est ignoré).</summary>
private TrustState LoadState()
{
lock (_lock)
{
if (_state is not null) return _state;
var state = new TrustState();
try
{
if (File.Exists(StatePath) && new FileInfo(StatePath).Length <= Keyring.MaxBytes * 2)
{
var read = JsonSerializer.Deserialize<TrustState>(File.ReadAllText(StatePath), ApiJson.Options);
if (read is not null)
{
state.LastSigned = new Dictionary<string, DateTimeOffset>(read.LastSigned ?? new(), StringComparer.Ordinal);
var errors = new List<string>();
if (read.Keyring is { } keyring && Keyring.Validate(keyring, errors) && Keyring.VerifyRoot(keyring, _rootKeys, out _)
&& keyring.Sequence >= MinKeyringSequence)
state.Keyring = keyring;
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"État de vérification des signatures illisible ({ex.Message}) : recréé.");
}
return _state = state;
}
}
private void SaveState(TrustState state)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!);
FileUtil.WriteAllTextAtomic(StatePath, JsonSerializer.Serialize(state, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true }));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"État de vérification des signatures non enregistré : {ex.Message}");
}
}
}
@@ -0,0 +1,308 @@
using System.Globalization;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>Textes communs des pages « Administrateurs » et « Mes appareils ».</summary>
public static class AccountTexts
{
/// <summary>« 01/11/2026 à 13:40 » (heure du PC).</summary>
public static string Date(DateTimeOffset value) =>
value.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
public static string Status(string status) => status switch
{
AdminStatus.Active => "Actif",
AdminStatus.Pending => "En attente d'approbation",
AdminStatus.Revoked => "Révoqué",
_ => status
};
/// <summary>Ce que permet chaque rôle (liste des rôles proposés).</summary>
public static string RoleHint(string role) => role switch
{
AdminRoles.Owner => "tous les droits, dont la gestion des administrateurs",
AdminRoles.Admin => "jeux, notes de version, publication et journal d'audit",
AdminRoles.Editor => "informations des jeux et notes de version",
_ => ""
};
public static string KeyLabel(string? label) => string.IsNullOrWhiteSpace(label) ? "Appareil sans nom" : label;
/// <summary>Son propre appareil en attente : l'empreinte se lit sur le nouveau PC.</summary>
public const string OwnPendingText = "Empreinte affichée sur le nouveau PC : saisissez-la ci-dessous pour l'approuver.";
}
/// <summary>
/// Actions communes aux pages de comptes (approuver ou révoquer un appareil, supprimer une invitation,
/// afficher un code d'invitation) : confirmation, appel signé, puis rechargement de la page.
/// </summary>
public abstract class AccountsPanelBase : AdminPanelViewModel
{
private InvitationCodeViewModel? _createdInvitation;
private string? _statusMessage;
protected AccountsPanelBase(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
}
public AsyncRelayCommand RefreshCommand { get; }
/// <summary>Faux pendant un échange avec le serveur (boutons des listes désactivés).</summary>
public bool CanEdit => !IsBusy;
/// <summary>Résultat de la dernière action (« Appareil approuvé. »…).</summary>
public string? StatusMessage
{
get => _statusMessage;
protected set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Code d'une invitation tout juste créée : affiché une seule fois, jusqu'à « J'ai transmis le code ».</summary>
public InvitationCodeViewModel? CreatedInvitation
{
get => _createdInvitation;
private set
{
if (!SetProperty(ref _createdInvitation, value)) return;
OnPropertyChanged(nameof(HasCreatedInvitation));
OnCommandsChanged(); // pas de nouvelle invitation tant qu'un code non transmis est affiché
}
}
public bool HasCreatedInvitation => _createdInvitation is not null;
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
RefreshCommand.RaiseCanExecuteChanged();
OnCommandsChanged();
}
/// <summary>Réévalue les commandes propres à la page.</summary>
protected virtual void OnCommandsChanged()
{
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
/// <summary>Relit les données de la page sur le serveur.</summary>
protected abstract Task ReloadAsync();
/// <summary>
/// Modification signée, puis rechargement SÉPARÉ : si seul le rechargement échoue, la modification
/// (faite sur le serveur) est quand même annoncée, avec l'erreur du rechargement.
/// </summary>
protected async Task<bool> ApplyAsync(Func<Task> change, string success)
{
StatusMessage = null;
if (!await RunAsync(change)) return false;
StatusMessage = success;
await RunAsync(ReloadAsync);
return true;
}
protected void ShowInvitation(InvitationCreated created, string title, string instructions)
{
StatusMessage = null;
CreatedInvitation = new InvitationCodeViewModel(created.Code, title, instructions, created.Invitation.ExpiresAt,
Context.Dialogs, () => CreatedInvitation = null);
}
internal async Task ApproveAsync(AdminKeyItem key)
{
StatusMessage = null;
var typed = AdminKeys.NormalizeFingerprint(key.FingerprintInput);
if (typed is null)
{
ErrorMessage = "Empreinte incomplète : saisissez les 20 caractères (5 groupes de 4) affichés sur le nouvel appareil.";
return;
}
if (!Context.Dialogs.Confirm($"Approuver l'appareil « {key.Label} » ?",
$"{key.AdminName} pourra se connecter depuis cet appareil.\n\nN'approuvez que si l'empreinte vous a été " +
"communiquée par la personne elle-même (téléphone, message), et non recopiée depuis un message reçu d'un inconnu.",
"Approuver"))
return;
await ApplyAsync(() => Context.Session.ApproveKeyAsync(key.AdminId, key.KeyId, typed),
$"Appareil « {key.Label} » de {key.AdminName} approuvé.");
}
internal async Task RevokeKeyAsync(AdminKeyItem key)
{
if (!Context.Dialogs.Confirm($"Révoquer l'appareil « {key.Label} » ?",
$"{key.AdminName} ne pourra plus se connecter depuis cet appareil ; les sessions qui l'utilisent sont fermées " +
"immédiatement. Une clé révoquée ne peut pas être réactivée.", "Révoquer", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeKeyAsync(key.AdminId, key.KeyId),
$"Appareil « {key.Label} » de {key.AdminName} révoqué.");
}
internal async Task DeleteInvitationAsync(InvitationItem invitation)
{
if (!Context.Dialogs.Confirm("Supprimer l'invitation ?",
$"{invitation.Title} : le code ne pourra plus être utilisé.", "Supprimer", danger: true))
return;
await ApplyAsync(async () =>
{
try
{
await Context.Session.DeleteInvitationAsync(invitation.Id);
}
catch (ApiException ex) when (ex.Status == 404)
{
// déjà utilisée ou expirée entre-temps
}
}, "Invitation supprimée.");
}
}
/// <summary>Appareil (clé) d'un administrateur.</summary>
public sealed class AdminKeyItem : ObservableObject
{
private string _fingerprintInput = "";
private readonly string? _fingerprintWarning;
internal AdminKeyItem(AccountsPanelBase page, Guid adminId, string adminName, AdminKeySummary key, bool isCurrentSession,
bool canApprove, string? approveHint, bool canRevoke, string pendingText)
{
// Cet appareil : empreinte calculée sur le PC (celle à dicter lors de la vérification du trousseau), pas celle
// annoncée par le serveur ; une différence est signalée.
var local = isCurrentSession ? page.Context.Session.LocalFingerprint : null;
if (local is not null && key.Fingerprint is not null &&
!string.Equals(AdminKeys.NormalizeFingerprint(local), AdminKeys.NormalizeFingerprint(key.Fingerprint), StringComparison.Ordinal))
_fingerprintWarning = $" · ATTENTION : le serveur annonce une autre empreinte ({key.Fingerprint})";
else if (local is not null)
_fingerprintWarning = " · empreinte calculée sur ce PC";
FingerprintText = local ?? key.Fingerprint ?? pendingText;
AdminId = adminId;
AdminName = adminName;
KeyId = key.KeyId;
Label = AccountTexts.KeyLabel(key.Label);
Status = key.Status;
IsCurrentSession = isCurrentSession;
Fingerprint = local ?? key.Fingerprint;
AddedText = $"Ajouté le {AccountTexts.Date(key.AddedAt)}";
LastUsedText = key.LastUsedAt is { } used ? $"Dernière connexion le {AccountTexts.Date(used)}" : "Jamais utilisé";
CanApprove = canApprove && IsPending;
ApproveHint = IsPending ? approveHint : null;
CanRevoke = canRevoke && !IsRevoked && !isCurrentSession;
ApproveCommand = new AsyncRelayCommand(() => page.ApproveAsync(this), () => CanApprove);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeKeyAsync(this), () => CanRevoke);
}
public Guid AdminId { get; }
public string AdminName { get; }
public string KeyId { get; }
public string Label { get; }
public string Status { get; }
public string StatusText => IsCurrentSession ? "Cet appareil (session en cours)" : AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsActive => Status == AdminStatus.Active;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsCurrentSession { get; }
/// <summary>Empreinte (clés actives ou révoquées) ; masquée par le serveur pour une clé en attente.</summary>
public string? Fingerprint { get; }
/// <summary>Empreinte, ou (clé en attente) où la trouver : sur le nouvel appareil.</summary>
public string FingerprintText { get; }
public string AddedText { get; }
public string LastUsedText { get; }
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}{_fingerprintWarning}";
public bool CanApprove { get; }
/// <summary>Pourquoi ce n'est pas vous qui approuvez (nouvel appareil d'un autre administrateur…).</summary>
public string? ApproveHint { get; }
public bool HasApproveHint => !string.IsNullOrEmpty(ApproveHint);
public bool CanRevoke { get; }
/// <summary>Empreinte dictée par la personne (« AB12 CD34 EF56 7890 1A2B »).</summary>
public string FingerprintInput
{
get => _fingerprintInput;
set => SetProperty(ref _fingerprintInput, value ?? "");
}
public AsyncRelayCommand ApproveCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
}
/// <summary>Invitation en cours (code pas encore utilisé).</summary>
public sealed class InvitationItem
{
internal InvitationItem(AccountsPanelBase page, InvitationSummary invitation, bool canDelete)
{
Id = invitation.Id;
IsDevice = invitation.ForAdminId is not null;
Title = IsDevice
? $"Nouvel appareil de {invitation.DisplayName}"
: $"{invitation.DisplayName} · {AdminTexts.Role(invitation.Role)}";
DetailText = $"Créée par {invitation.CreatedBy ?? "le serveur"} le {AccountTexts.Date(invitation.CreatedAt)} · " +
$"expire le {AccountTexts.Date(invitation.ExpiresAt)}";
CanDelete = canDelete;
DeleteCommand = new AsyncRelayCommand(() => page.DeleteInvitationAsync(this), () => CanDelete);
}
public Guid Id { get; }
public bool IsDevice { get; }
public string Title { get; }
public string DetailText { get; }
public bool CanDelete { get; }
public AsyncRelayCommand DeleteCommand { get; }
}
/// <summary>
/// Code d'une invitation tout juste créée. Le serveur n'en garde que l'empreinte : il n'est affiché
/// qu'ici, une seule fois, et oublié dès que l'administrateur indique l'avoir transmis.
/// </summary>
public sealed class InvitationCodeViewModel : ObservableObject
{
private readonly IModuleDialogs _dialogs;
private bool _copied;
internal InvitationCodeViewModel(string code, string title, string instructions, DateTimeOffset expiresAt,
IModuleDialogs dialogs, Action dismiss)
{
Code = code;
Title = title;
Instructions = instructions;
ExpiresText = $"Valable une seule fois, jusqu'au {AccountTexts.Date(expiresAt)}. Il ne sera plus affiché.";
_dialogs = dialogs;
CopyCommand = new RelayCommand(Copy);
DismissCommand = new RelayCommand(dismiss);
}
public string Code { get; }
public string Title { get; }
public string Instructions { get; }
public string ExpiresText { get; }
public bool Copied
{
get => _copied;
private set => SetProperty(ref _copied, value);
}
public RelayCommand CopyCommand { get; }
/// <summary>« J'ai transmis le code » : le code disparaît de l'écran.</summary>
public RelayCommand DismissCommand { get; }
private void Copy()
{
_dialogs.CopyToClipboard(Code);
Copied = true;
}
}
@@ -0,0 +1,35 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Module « Comptes » : page « Mes appareils » (tout administrateur) et page « Administrateurs »
/// (gestion des administrateurs : propriétaires).
/// </summary>
public sealed class AccountsAdminModule : ILauncherModule
{
public string Id => "accounts";
public string DisplayName => "Comptes";
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor(
MyDevicesPanelViewModel.PanelId,
"Mes appareils",
"Les PC depuis lesquels vous pouvez vous connecter : ajoutez-en un (invitation « nouvel appareil »), puis approuvez-le " +
"ici avec l'empreinte qu'il affiche.",
"KeyVariant",
RequiredPermission: null,
Order: 40,
context => new MyDevicesPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
AdminsPanelViewModel.PanelId,
"Administrateurs",
"Invitez un administrateur, approuvez ses appareils par leur empreinte, changez son rôle ou révoquez-le.",
"AccountGroup",
AdminPermissions.AdminsManage,
Order: 50,
context => new AdminsPanelViewModel(context)));
}
}
@@ -0,0 +1,263 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Administrateurs » (admins.manage) : comptes et appareils, invitations, approbation par
/// empreinte, rôles, révocation. Le serveur reste seul juge : cette page ne propose que ce qu'il
/// accepte (pas de changement de son propre rôle, pas d'appareil approuvé au nom d'un autre
/// administrateur actif…) et affiche ses refus tels quels.
/// </summary>
public sealed class AdminsPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.manage";
private List<AdminSummary> _summaries = new();
private IReadOnlyList<AdminAccountItem> _admins = Array.Empty<AdminAccountItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
private bool _showRevoked;
private string _newName = "";
private Choice _newRole;
public AdminsPanelViewModel(IAdminContext context) : base(context)
{
RoleChoices = AdminRoles.All.Select(r => new Choice(r, AdminTexts.Role(r))).ToList();
_newRole = RoleChoices.First(c => c.Value == AdminRoles.Editor);
InviteCommand = new AsyncRelayCommand(InviteAsync, () => !IsBusy && !HasCreatedInvitation && !string.IsNullOrWhiteSpace(_newName));
}
public IReadOnlyList<Choice> RoleChoices { get; }
public IReadOnlyList<AdminAccountItem> Admins
{
get => _admins;
private set => SetProperty(ref _admins, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
/// <summary>Afficher aussi les comptes révoqués (historique).</summary>
public bool ShowRevoked
{
get => _showRevoked;
set
{
if (SetProperty(ref _showRevoked, value)) BuildAdmins();
}
}
public string RevokedText
{
get
{
var count = _summaries.Count(a => a.Status == AdminStatus.Revoked);
return count == 0 ? "Aucun compte révoqué" : $"Afficher les comptes révoqués ({count})";
}
}
public string SummaryText
{
get
{
var active = _summaries.Count(a => a.Status == AdminStatus.Active);
var pending = _summaries.Where(a => a.Status != AdminStatus.Revoked).SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
return $"{active} compte(s) actif(s)" + (pending == 0 ? "" : $" · {pending} appareil(s) en attente d'approbation");
}
}
// ------------------------------------------------------------------ Nouvelle invitation
public string NewName
{
get => _newName;
set
{
if (SetProperty(ref _newName, value ?? "")) InviteCommand.RaiseCanExecuteChanged();
}
}
public Choice NewRole
{
get => _newRole;
set
{
if (value is not null && SetProperty(ref _newRole, value)) OnPropertyChanged(nameof(NewRoleHint));
}
}
public string NewRoleHint => $"{AdminTexts.Role(_newRole.Value)} : {AccountTexts.RoleHint(_newRole.Value ?? "")}.";
public AsyncRelayCommand InviteCommand { get; }
protected override void OnCommandsChanged() => InviteCommand.RaiseCanExecuteChanged();
private async Task InviteAsync()
{
var name = _newName.Trim();
var role = _newRole.Value!;
if (role == AdminRoles.Owner &&
!Context.Dialogs.Confirm("Inviter un propriétaire ?",
$"{name} aura tous les droits, dont celui de gérer les administrateurs (y compris de révoquer votre compte).",
"Inviter", danger: true))
return;
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = name, Role = role })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
NewName = "";
ShowInvitation(created!, $"Invitation de {created!.Invitation.DisplayName} ({AdminTexts.Role(created.Invitation.Role)})",
$"Transmettez ce code à {created.Invitation.DisplayName}. Dans le launcher : menu d'administration › « J'ai une invitation ». " +
"Le launcher lui affichera ensuite une empreinte : faites-la-vous dicter (téléphone, message), puis approuvez son appareil " +
"ci-dessous en la saisissant.");
await RunAsync(ReloadAsync);
}
// ------------------------------------------------------------------ Comptes
internal async Task ChangeRoleAsync(AdminAccountItem admin)
{
var role = admin.SelectedRole.Value!;
if (role == admin.Role) return;
var message = $"{admin.DisplayName} : {AdminTexts.Role(admin.Role)} → {AdminTexts.Role(role)}. Le changement s'applique " +
"immédiatement, y compris à ses sessions ouvertes.";
if (role == AdminRoles.Owner) message += "\n\nUn propriétaire peut gérer tous les administrateurs, y compris révoquer votre compte.";
if (!Context.Dialogs.Confirm("Changer le rôle ?", message, "Changer le rôle", danger: role == AdminRoles.Owner))
{
admin.ResetRole();
return;
}
if (!await ApplyAsync(() => Context.Session.ChangeRoleAsync(admin.Id, role),
$"{admin.DisplayName} est maintenant {AdminTexts.Role(role).ToLowerInvariant()}."))
admin.ResetRole();
}
internal async Task RevokeAdminAsync(AdminAccountItem admin)
{
if (!Context.Dialogs.Confirm($"Révoquer le compte de {admin.DisplayName} ?",
"Ses sessions sont fermées et tous ses appareils révoqués immédiatement ; ses invitations « nouvel appareil » sont " +
"supprimées. Un compte révoqué ne peut pas être réactivé : il faudrait l'inviter de nouveau.",
"Révoquer le compte", danger: true))
return;
await ApplyAsync(() => Context.Session.RevokeAdminAsync(admin.Id), $"Compte de {admin.DisplayName} révoqué.");
}
// ------------------------------------------------------------------ Chargement
protected override async Task ReloadAsync()
{
var admins = await Context.Session.ListAdminsAsync();
var invitations = await Context.Session.ListInvitationsAsync();
_summaries = admins;
BuildAdmins();
Invitations = invitations.Select(i => new InvitationItem(this, i, canDelete: true)).ToList();
OnPropertyChanged(nameof(RevokedText));
OnPropertyChanged(nameof(SummaryText));
}
private void BuildAdmins()
{
var me = Context.Session.Profile;
// À traiter d'abord : comptes en attente, puis comptes actifs (vous en premier), puis révoqués.
Admins = _summaries
.Where(a => _showRevoked || a.Status != AdminStatus.Revoked)
.OrderBy(a => a.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenBy(a => a.Id == me.Id ? 0 : 1)
.ThenBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(a => new AdminAccountItem(this, a, me.Id, me.KeyId, RoleChoices))
.ToList();
}
}
/// <summary>Compte d'un administrateur et ses appareils.</summary>
public sealed class AdminAccountItem : ObservableObject
{
private readonly IReadOnlyList<Choice> _roles;
private Choice _selectedRole;
internal AdminAccountItem(AdminsPanelViewModel page, AdminSummary admin, Guid myId, string myKeyId, IReadOnlyList<Choice> roles)
{
_roles = roles;
Id = admin.Id;
DisplayName = admin.DisplayName;
Initials = AdminTexts.Initials(admin.DisplayName);
Role = admin.Role;
Status = admin.Status;
IsYou = admin.Id == myId;
CreatedText = $"Invité le {AccountTexts.Date(admin.CreatedAt)}";
_selectedRole = roles.FirstOrDefault(r => r.Value == admin.Role) ?? roles[^1];
var hasActiveKey = admin.Keys.Any(k => k.Status == AdminStatus.Active);
// Premier appareil (compte en attente) : approuvé par un propriétaire. Nouvel appareil d'un compte
// actif : par son titulaire seulement, depuis un de ses appareils actifs (règle du serveur).
string? hint = null;
var canApprove = !IsRevoked && (Status == AdminStatus.Pending || IsYou);
if (!canApprove && Status == AdminStatus.Active)
hint = hasActiveKey
? $"Nouvel appareil : {DisplayName} l'approuve lui-même depuis un de ses appareils actifs (page « Mes appareils »)."
: $"{DisplayName} n'a plus aucun appareil actif : restaurez son accès sur le VPS (« add-key »), ou révoquez ce compte et invitez-le de nouveau.";
Keys = admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(page, admin.Id, admin.DisplayName, k, IsYou && k.KeyId == myKeyId,
canApprove, hint, canRevoke: !IsRevoked,
IsYou ? AccountTexts.OwnPendingText : $"Empreinte affichée sur le nouvel appareil : faites-la-vous dicter par {admin.DisplayName}."))
.ToList();
ChangeRoleCommand = new AsyncRelayCommand(() => page.ChangeRoleAsync(this), () => CanChangeRole && RoleChanged);
RevokeCommand = new AsyncRelayCommand(() => page.RevokeAdminAsync(this), () => CanRevoke);
}
public Guid Id { get; }
public string DisplayName { get; }
public string Initials { get; }
public string Role { get; }
public string RoleText => AdminTexts.Role(Role);
public string Status { get; }
public string StatusText => AccountTexts.Status(Status);
public bool IsPending => Status == AdminStatus.Pending;
public bool IsRevoked => Status == AdminStatus.Revoked;
public bool IsYou { get; }
public string CreatedText { get; }
public IReadOnlyList<AdminKeyItem> Keys { get; }
/// <summary>On ne change ni son propre rôle ni celui d'un compte révoqué ; on ne se révoque pas soi-même.</summary>
public bool CanChangeRole => !IsYou && !IsRevoked;
public bool CanRevoke => !IsYou && !IsRevoked;
public IReadOnlyList<Choice> RoleChoices => _roles;
public Choice SelectedRole
{
get => _selectedRole;
set
{
if (value is null || !SetProperty(ref _selectedRole, value)) return;
OnPropertyChanged(nameof(RoleChanged));
ChangeRoleCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Rôle choisi différent du rôle actuel : bouton « Appliquer » affiché.</summary>
public bool RoleChanged => _selectedRole.Value != Role;
public AsyncRelayCommand ChangeRoleCommand { get; }
public AsyncRelayCommand RevokeCommand { get; }
internal void ResetRole() => SelectedRole = _roles.FirstOrDefault(r => r.Value == Role) ?? _selectedRole;
}
@@ -0,0 +1,81 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Accounts;
/// <summary>
/// Page « Mes appareils » (tout administrateur connecté) : ses clés, l'ajout d'un nouveau PC (invitation
/// « nouvel appareil », puis approbation ici avec l'empreinte affichée sur le nouveau PC), ses invitations.
/// Personne d'autre ne peut approuver un appareil à son nom (règle du serveur).
/// </summary>
public sealed class MyDevicesPanelViewModel : AccountsPanelBase
{
public const string PanelId = "admins.devices";
private IReadOnlyList<AdminKeyItem> _devices = Array.Empty<AdminKeyItem>();
private IReadOnlyList<InvitationItem> _invitations = Array.Empty<InvitationItem>();
public MyDevicesPanelViewModel(IAdminContext context) : base(context)
{
AddDeviceCommand = new AsyncRelayCommand(AddDeviceAsync, () => !IsBusy && !HasCreatedInvitation);
}
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
/// <summary>Révoquer un de ses appareils exige la gestion des administrateurs (sinon : un propriétaire s'en charge).</summary>
public bool CanRevokeDevices => Context.Session.Has(AdminPermissions.AdminsManage);
public IReadOnlyList<AdminKeyItem> Devices
{
get => _devices;
private set => SetProperty(ref _devices, value);
}
public IReadOnlyList<InvitationItem> Invitations
{
get => _invitations;
private set
{
if (SetProperty(ref _invitations, value)) OnPropertyChanged(nameof(HasInvitations));
}
}
public bool HasInvitations => _invitations.Count > 0;
public AsyncRelayCommand AddDeviceCommand { get; }
protected override void OnCommandsChanged() => AddDeviceCommand.RaiseCanExecuteChanged();
private async Task AddDeviceAsync()
{
StatusMessage = null;
InvitationCreated? created = null;
if (!await RunAsync(async () =>
created = await Context.Session.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = Context.Session.Profile.Id })))
return;
// Code affiché AVANT le rechargement : un échec de celui-ci ne doit pas le faire perdre (il n'existe qu'ici).
ShowInvitation(created!, "Invitation pour votre nouvel appareil",
"Sur le nouveau PC : launcher › menu d'administration › « J'ai une invitation », puis collez ce code. Le nouveau PC " +
"affiche ensuite une empreinte : revenez ici, saisissez-la sous l'appareil en attente et approuvez-le.");
await RunAsync(ReloadAsync);
}
protected override async Task ReloadAsync()
{
var account = await Context.Session.GetMyAccountAsync();
var me = Context.Session.Profile;
Devices = account.Admin.Keys
.OrderBy(k => k.Status switch { AdminStatus.Pending => 0, AdminStatus.Active => 1, _ => 2 })
.ThenByDescending(k => k.AddedAt)
.Select(k => new AdminKeyItem(this, account.Admin.Id, account.Admin.DisplayName, k, k.KeyId == me.KeyId,
canApprove: true, approveHint: null, canRevoke: CanRevokeDevices, AccountTexts.OwnPendingText))
.ToList();
Invitations = account.Invitations
.Select(i => new InvitationItem(this, i, canDelete: i.CreatedById == me.Id || CanRevokeDevices))
.ToList();
}
}
@@ -0,0 +1,21 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>Module « Audit » : page « Journal d'audit » (permission audit.read : propriétaires, administrateurs).</summary>
public sealed class AuditAdminModule : ILauncherModule
{
public string Id => "audit";
public string DisplayName => "Audit";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
AuditPanelViewModel.PanelId,
"Journal d'audit",
"Connexions, publications, modifications et refus enregistrés par le serveur, les plus récents d'abord.",
"History",
AdminPermissions.AuditRead,
Order: 60,
context => new AuditPanelViewModel(context)));
}
@@ -0,0 +1,332 @@
using System.Globalization;
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Audit;
/// <summary>
/// Page « Journal d'audit » (audit.read) : actions enregistrées par le serveur, les plus récentes
/// d'abord, chargées par pages de 200 ; filtres (texte, catégorie, résultat) appliqués aux entrées
/// chargées ; export CSV de ce qui est affiché.
/// </summary>
public sealed class AuditPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "audit.read";
/// <summary>Entrées par page (maximum accepté par le serveur).</summary>
public const int PageSize = 200;
/// <summary>Au-delà, « Charger plus » s'arrête (mémoire du launcher) : utiliser journalctl ou les fichiers du VPS.</summary>
public const int MaxLoaded = 5000;
/// <summary>Entrées affichées au plus (la page n'est pas virtualisée) ; l'export reprend toutes celles filtrées.</summary>
public const int MaxDisplayed = 500;
private readonly List<AuditEntry> _loaded = new();
private IReadOnlyList<AuditItem> _filtered = Array.Empty<AuditItem>();
private string? _nextCursor;
private IReadOnlyList<AuditItem> _entries = Array.Empty<AuditItem>();
private string _searchText = "";
private Choice _category;
private Choice _result;
private string? _lastRefresh;
private bool _hasLoaded;
public AuditPanelViewModel(IAdminContext context) : base(context)
{
Categories = new List<Choice>
{
new(null, "Toutes les actions"),
new("auth.", "Connexions"),
new("admins.", "Administrateurs"),
new("cli.", "Outil du VPS (ligne de commande)"),
new("catalog.", "Informations des jeux"),
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
{
new(null, "Tous les résultats"),
new(AuditResults.Ok, "Réussis"),
new(AuditResults.Denied, "Refusés")
};
_category = Categories[0];
_result = Results[0];
RefreshCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: true)), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: false)), () => !IsBusy && CanLoadMore);
ClearFiltersCommand = new RelayCommand(ClearFilters, () => HasFilters);
ExportCommand = new AsyncRelayCommand(ExportAsync, () => !IsBusy && _entries.Count > 0);
}
public IReadOnlyList<Choice> Categories { get; }
public IReadOnlyList<Choice> Results { get; }
public IReadOnlyList<AuditItem> Entries
{
get => _entries;
private set
{
if (!SetProperty(ref _entries, value)) return;
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(EmptyText));
OnPropertyChanged(nameof(TruncatedText));
OnPropertyChanged(nameof(IsTruncated));
ExportCommand.RaiseCanExecuteChanged();
}
}
public bool HasEntries => _entries.Count > 0;
public bool IsTruncated => _filtered.Count > _entries.Count;
public string TruncatedText => IsTruncated
? $"Seules les {MaxDisplayed} premières entrées sont affichées : affinez la recherche, ou exportez les {_filtered.Count} entrées filtrées."
: "";
/// <summary>Message quand rien n'est affiché (vide tant que rien n'a été chargé : l'erreur éventuelle suffit).</summary>
public string EmptyText => !_hasLoaded || HasEntries ? ""
: _loaded.Count == 0 ? "Le journal est vide."
: "Aucune entrée chargée ne correspond aux filtres" + (CanLoadMore ? " : chargez des entrées plus anciennes." : ".");
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) ApplyFilters();
}
}
public Choice Category
{
get => _category;
set
{
if (value is not null && SetProperty(ref _category, value)) ApplyFilters();
}
}
public Choice Result
{
get => _result;
set
{
if (value is not null && SetProperty(ref _result, value)) ApplyFilters();
}
}
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null;
/// <summary>« 37 entrées affichées sur 200 chargées (depuis le 12/10/2026 à 08:14) ».</summary>
public string SummaryText
{
get
{
if (_loaded.Count == 0) return "";
var oldest = _loaded[^1].Timestamp.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
var shown = HasFilters ? $"{_filtered.Count} entrée(s) sur {_loaded.Count} chargée(s)" : $"{_loaded.Count} entrée(s) chargée(s)";
return $"{shown}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
: _loaded.Count >= MaxLoaded ? $". Limite de {MaxLoaded} entrées atteinte : les plus anciennes restent dans les fichiers du journal sur le VPS."
: ".");
}
}
public bool CanLoadMore => _nextCursor is not null && _loaded.Count < MaxLoaded;
public string? LastRefresh
{
get => _lastRefresh;
private set => SetProperty(ref _lastRefresh, value);
}
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public RelayCommand ClearFiltersCommand { get; }
/// <summary>Exporte les entrées filtrées (toutes, pas seulement celles affichées).</summary>
public AsyncRelayCommand ExportCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
LoadMoreCommand.RaiseCanExecuteChanged();
ExportCommand.RaiseCanExecuteChanged();
}
/// <summary>Chargé au premier affichage ; ensuite « Actualiser » (garde les pages déjà chargées et les filtres d'ici là).</summary>
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync(reset: true)) : Task.CompletedTask;
private async Task LoadAsync(bool reset)
{
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor);
if (reset) _loaded.Clear();
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
_hasLoaded = true;
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
OnPropertyChanged(nameof(CanLoadMore));
LoadMoreCommand.RaiseCanExecuteChanged();
ApplyFilters();
}
private void ClearFilters()
{
_searchText = "";
_category = Categories[0];
_result = Results[0];
OnPropertyChanged(nameof(SearchText));
OnPropertyChanged(nameof(Category));
OnPropertyChanged(nameof(Result));
ApplyFilters();
}
private void ApplyFilters()
{
var text = _searchText.Trim();
_filtered = _loaded
.Where(e => AuditTexts.InCategory(e.Action, _category.Value))
.Where(e => _result.Value is null || e.Result == _result.Value)
.Select(e => new AuditItem(e))
.Where(item => text.Length == 0 || item.Matches(text))
.ToList();
Entries = _filtered.Count > MaxDisplayed ? _filtered.Take(MaxDisplayed).ToList() : _filtered;
OnPropertyChanged(nameof(HasFilters));
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(EmptyText));
ClearFiltersCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Export
private async Task ExportAsync()
{
var path = Context.Dialogs.PickSaveFile("Exporter le journal d'audit", "Fichier CSV (*.csv)|*.csv",
$"journal-audit-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
if (path is null) return;
var entries = _filtered;
if (await RunAsync(() => File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true))))
Context.Dialogs.ShowMessage("Journal exporté", $"{entries.Count} entrée(s) enregistrée(s) dans {Path.GetFileName(path)}.",
ModuleMessageKind.Success);
}
}
/// <summary>Entrée du journal telle qu'affichée.</summary>
public sealed class AuditItem
{
internal AuditItem(AuditEntry entry)
{
Entry = entry;
TimeText = entry.Timestamp.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
ActionText = AuditTexts.Action(entry.Action);
ResultText = AuditTexts.Result(entry.Result);
ActorText = entry.ActorName ?? (entry.KeyId is null ? "Inconnu" : "Clé inconnue");
var parts = new List<string>();
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
DetailText = string.Join(" — ", parts);
var origin = new List<string>();
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
OriginText = string.Join(" · ", origin);
}
public AuditEntry Entry { get; }
public string TimeText { get; }
public string ActionText { get; }
/// <summary>Code technique (« admins.key_approve »), en infobulle.</summary>
public string ActionCode => Entry.Action;
public string Result => Entry.Result;
public string ResultText { get; }
public bool IsOk => Entry.Result == AuditResults.Ok;
public bool IsDenied => Entry.Result == AuditResults.Denied;
public bool IsFailed => Entry.Result == AuditResults.Failed;
public string ActorText { get; }
public string DetailText { get; }
public bool HasDetail => DetailText.Length > 0;
public string OriginText { get; }
/// <summary>Recherche dans tout ce qui est affiché (sans tenir compte de la casse).</summary>
internal bool Matches(string text) =>
new[] { TimeText, ActionText, Entry.Action, ResultText, ActorText, DetailText, OriginText }
.Any(value => value.Contains(text, StringComparison.CurrentCultureIgnoreCase));
}
/// <summary>Libellés du journal (français) et export CSV.</summary>
public static class AuditTexts
{
public static string Action(string action) => action switch
{
"auth.login" => "Connexion",
"auth.logout" => "Déconnexion",
"admins.enroll" => "Appareil enregistré avec une invitation",
"admins.invite" => "Invitation créée",
"admins.invite_deleted" => "Invitation supprimée",
"admins.key_approve" => "Appareil approuvé",
"admins.key_revoke" => "Appareil révoqué",
"admins.role" => "Rôle modifié",
"admins.revoke" => "Compte révoqué",
"cli.add_owner" => "Propriétaire ajouté sur le VPS",
"cli.add_key" => "Clé ajoutée sur le VPS",
"cli.revoke_admin" => "Compte révoqué sur le VPS",
"cli.revoke_key" => "Clé révoquée sur le VPS",
"catalog.publish" => "Informations des jeux publiées",
"changelog.edit" => "Notes de version modifiées",
"release.start" => "Publication d'une version commencée",
"release.publish" => "Version mise en ligne",
"release.cancel" => "Publication abandonnée",
"request.signature" => "Requête signée refusée",
"audit.suppressed" => "Tentatives refusées regroupées",
_ => action
};
public static string Result(string result) => result switch
{
AuditResults.Ok => "Réussi",
AuditResults.Denied => "Refusé",
AuditResults.Failed => "Échec",
_ => result
};
/// <summary>Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées.</summary>
public static bool InCategory(string action, string? category) => category switch
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
/// <summary>
/// CSV (séparateur « ; », pour Excel en français). Les valeurs viennent en partie de requêtes anonymes ou
/// de saisies (nom du client, de l'appareil…) : une cellule qui commencerait par = + - @ serait interprétée
/// comme une formule par le tableur ; elle est donc précédée d'une apostrophe, et toutes les cellules sont
/// entre guillemets (un tableur qui sépare par des virgules ne peut pas couper une valeur en deux).
/// </summary>
public static string ToCsv(IEnumerable<AuditItem> items)
{
var csv = new StringBuilder();
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
foreach (var item in items)
{
var e = item.Entry;
csv.AppendLine(string.Join(';', new[]
{
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
item.ActionText, e.Action, item.ResultText, e.ActorName, e.Ip, e.KeyId, e.Target, e.Details
}.Select(Cell)));
}
return csv.ToString();
}
private static string Cell(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
value = value.Replace("\r", " ").Replace("\n", " ");
var first = value.TrimStart(' ');
if (value[0] == '\t' || (first.Length > 0 && first[0] is '=' or '+' or '-' or '@')) value = "'" + value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}
@@ -4,6 +4,7 @@ using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
@@ -186,7 +187,10 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
try
{
var published = await Context.Session.PublishCatalogAsync(draft, state.Catalog.Revision);
// Signature (phase 7) du catalogue tel qu'il sera publié (contrôlé avec les mêmes règles que le serveur).
var signature = await Context.Session.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId,
ContentSigning.CatalogDigest(normalized));
var published = await Context.Session.PublishCatalogAsync(normalized with { Signature = signature }, state.Catalog.Revision);
Apply(published);
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>
/// Module « Publication » : page du menu d'administration pour mettre en ligne une version produite par
/// le .bat (ManifestBuilder), sans WinSCP. Permission releases.publish (propriétaires, administrateurs).
/// </summary>
public sealed class PublishAdminModule : ILauncherModule
{
public string Id => "publishing";
public string DisplayName => "Publication";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
PublishPanelViewModel.PanelId,
"Publier une version",
"Mettez en ligne la version préparée par le .bat de publication (ManifestBuilder) : seuls les fichiers absents " +
"du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est publié en dernier.",
"CloudUpload",
AdminPermissions.ReleasesPublish,
Order: 30,
context => new PublishPanelViewModel(context)));
}
@@ -0,0 +1,443 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>
/// Page « Publier une version » : choix du manifest.json produit par le .bat (ManifestBuilder), contrôle
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète.
/// </summary>
public sealed class PublishPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "releases.publish";
private Choice? _selectedGame;
private LocalRelease? _release;
private string? _manifestPath, _publishedVersion, _statusMessage, _progressText, _speedText;
private string _publishedText = "";
private ManifestSignatureState? _online;
private IReadOnlyList<string> _warnings = Array.Empty<string>();
private bool _isPublishing, _committing;
private double _progressValue;
private PublicationState? _interrupted;
private CancellationTokenSource? _cancellation;
public PublishPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit);
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _manifestPath is not null);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => CanEdit && _release is not null);
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
SignOnlineCommand = new AsyncRelayCommand(SignOnlineAsync, () => CanEdit && CanSignOnline);
AbandonCommand = new AsyncRelayCommand(AbandonAsync, () => CanEdit && _interrupted is not null);
}
// ------------------------------------------------------------------ Jeu, dossier
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame) || _isPublishing) return;
_selectedGame = value;
OnPropertyChanged();
SetRelease(null);
_ = RefreshPublishedAsync();
}
}
public string? ManifestPath
{
get => _manifestPath;
private set
{
if (SetProperty(ref _manifestPath, value)) RecheckCommand.RaiseCanExecuteChanged();
}
}
/// <summary>« Version en ligne : 1.1 » (ou la raison pour laquelle elle est inconnue).</summary>
public string PublishedText
{
get => _publishedText;
private set => SetProperty(ref _publishedText, value);
}
// ------------------------------------------------------------------ Version à publier
public bool HasRelease => _release is not null;
public string VersionText => _release is null ? "" : $"Version {_release.Summary.Version}";
public string? NotesTitle => _release?.Summary.NotesTitle;
public bool HasNotesTitle => !string.IsNullOrWhiteSpace(NotesTitle);
public string FilesText => _release is null ? "" :
$"{_release.Summary.FileCount} fichier(s), {Format.Bytes(_release.Summary.TotalSize)} · exécutable « {_release.Summary.Executable} »";
public string ArchiveText => _release is null ? "" : $"{_release.Summary.ArchiveName} ({Format.Bytes(_release.Summary.ArchiveSize)})";
/// <summary>Points d'attention : republication, retour à une version précédente…</summary>
public IReadOnlyList<string> Warnings
{
get => _warnings;
private set
{
_warnings = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasWarnings));
}
}
public bool HasWarnings => _warnings.Count > 0;
// ------------------------------------------------------------------ Envoi
public bool IsPublishing
{
get => _isPublishing;
private set
{
if (!SetProperty(ref _isPublishing, value)) return;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
}
/// <summary>Faux pendant un contrôle ou un envoi.</summary>
public bool CanEdit => !IsBusy && !_isPublishing;
/// <summary>Barre d'attente du contrôle du dossier (l'envoi a sa propre barre de progression).</summary>
public bool ShowBusyBar => IsBusy && !_isPublishing;
public double ProgressValue
{
get => _progressValue;
private set => SetProperty(ref _progressValue, value);
}
public string? ProgressText
{
get => _progressText;
private set => SetProperty(ref _progressText, value);
}
public string? SpeedText
{
get => _speedText;
private set => SetProperty(ref _speedText, value);
}
/// <summary>
/// Envoi interrompu (annulé, coupure, erreur) : il reprendra à « Publier », ou peut être abandonné (fichiers
/// reçus supprimés). Pas pendant l'envoi lui-même : la publication est alors ouverte, pas interrompue.
/// </summary>
public bool HasInterrupted => _interrupted is not null && !_isPublishing;
public string InterruptedText => _interrupted is null ? "" :
$"L'envoi de la version {_interrupted.Version} a été interrompu. « Publier » le reprend là où il s'est arrêté ; " +
"« Abandonner » supprime ce qui a déjà été reçu par le serveur.";
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand ChooseCommand { get; }
public AsyncRelayCommand RecheckCommand { get; }
public AsyncRelayCommand PublishCommand { get; }
public RelayCommand CancelCommand { get; }
public AsyncRelayCommand AbandonCommand { get; }
// ------------------------------------------------------------------ Signature de la version en ligne (phase 7)
/// <summary>« Version en ligne signée par … » / « non signée ».</summary>
public string OnlineSignatureText => _online is not { Version.Length: > 0 } online ? ""
: online.Signature is { } signature
? $"Version en ligne {online.Version} signée par {online.SignerName ?? $"la clé {signature.KeyId}"} le {Accounts.AccountTexts.Date(signature.SignedAt)}."
: $"Version en ligne {online.Version} non signée : les launchers en mode « require » la refusent.";
public bool HasOnlineSignatureText => OnlineSignatureText.Length > 0;
public bool IsOnlineSigned => _online?.Signature is not null;
/// <summary>Le dossier choisi a exactement le contenu de la version en ligne : elle peut être signée avec lui.</summary>
public bool CanSignOnline => !_isPublishing && _release is { } release && _online is { Digest.Length: > 0 } online &&
string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase);
/// <summary>Même numéro de version que celle en ligne, mais contenu différent : impossible de la signer avec ce dossier.</summary>
public string? OnlineMismatchText => _release is { } release && _online is { Digest.Length: > 0 } online &&
release.Summary.Version == online.Version &&
!string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase)
? "La version en ligne porte ce numéro mais son contenu diffère de ce dossier : elle ne peut pas être signée avec lui."
: null;
public AsyncRelayCommand SignOnlineCommand { get; }
/// <summary>
/// Signe la version déjà en ligne (publiée avant la signature, ou avec WinSCP) à partir du dossier choisi : le
/// serveur n'accepte que si les fichiers en ligne sont exactement ceux de ce manifest.
/// </summary>
private async Task SignOnlineAsync()
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId || _online is not { } online) return;
if (!Context.Dialogs.Confirm($"Signer la version en ligne {online.Version} ?",
"Vous attestez que la version en ligne est bien celle de ce dossier de publication (le serveur vérifie que les fichiers, " +
"l'archive et l'exécutable sont identiques). Les launchers des joueurs l'accepteront ensuite.", "Signer"))
return;
StatusMessage = null;
if (await RunAsync(async () =>
{
var signature = await Context.Session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest);
_online = await Context.Session.SignOnlineManifestAsync(gameId,
new ManifestSignRequest { Digest = release.ContentDigest, Signature = signature });
}))
StatusMessage = $"Version en ligne {online.Version} signée.";
NotifySignature();
}
private async Task RefreshSignatureAsync(string gameId)
{
try
{
_online = await Context.Session.GetManifestSignatureAsync(gameId);
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
_online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
}
NotifySignature();
}
private void NotifySignature()
{
foreach (var name in new[] { nameof(OnlineSignatureText), nameof(HasOnlineSignatureText), nameof(IsOnlineSigned),
nameof(CanSignOnline), nameof(OnlineMismatchText) })
OnPropertyChanged(name);
SignOnlineCommand.RaiseCanExecuteChanged();
}
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshPublishedAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Actions
private async Task ChooseAsync()
{
var path = Context.Dialogs.PickOpenFile("Manifest de la version à publier (dossier produit par le .bat)",
"Manifest de publication (manifest.json)|manifest.json", _manifestPath is null ? null : Path.GetDirectoryName(_manifestPath));
if (path is not null) await InspectAsync(path);
}
/// <summary>Contrôle du dossier choisi (hors du fil de l'interface : lecture de nombreux fichiers).</summary>
public Task InspectAsync(string manifestPath) => RunAsync(async () =>
{
ManifestPath = manifestPath;
StatusMessage = null;
SetRelease(null);
if (_selectedGame?.Value is not { } gameId) return;
var release = await Task.Run(() => ReleaseUploader.Inspect(manifestPath, gameId));
await RefreshPublishedCoreAsync(gameId);
SetRelease(release);
});
private async Task PublishAsync()
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId) return;
var online = _publishedVersion switch
{
null => "version en ligne inconnue",
"" => "aucune version en ligne",
_ => $"version en ligne : {_publishedVersion}"
};
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
$"Les joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
return;
StatusMessage = null;
_committing = false;
IsPublishing = true;
// Créés ici, sur le fil de l'interface : l'envoi les rappelle sur ce fil (boutons, liaisons).
var progress = new Progress<UploadProgress>(OnProgress);
var started = new Progress<PublicationState>(OnStarted);
_cancellation = new CancellationTokenSource();
try
{
using var _ = Context.BeginLongOperation();
await RunAsync(async () =>
{
try
{
var result = await ReleaseUploader.PublishAsync(Context.Session, gameId, release, progress, started, _cancellation.Token);
SetInterrupted(null);
_publishedVersion = result.Version;
PublishedText = $"Version en ligne : {result.Version}";
StatusMessage = $"Version {result.Version} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
(result.ArchiveAdded ? " et l'archive" : "") +
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
UpdateWarnings();
Log.Info($"Administration : version {result.Version} de {gameId} publiée.");
await RefreshSignatureAsync(gameId);
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
{
StatusMessage = null;
ProgressText = "Envoi interrompu.";
}
});
}
finally
{
_cancellation.Dispose();
_cancellation = null;
_committing = false;
IsPublishing = false;
SpeedText = null;
}
}
private Task AbandonAsync() => RunAsync(async () =>
{
if (_interrupted is not { } publication) return;
if (!Context.Dialogs.Confirm("Abandonner la publication ?",
$"Les fichiers de la version {publication.Version} déjà reçus par le serveur seront supprimés. Rien n'a été mis en ligne.",
"Abandonner", danger: true))
return;
try
{
await Context.Session.CancelPublicationAsync(publication.PublicationId);
}
catch (ApiException ex) when (ex.Status == 404)
{
// déjà terminée ou expirée
}
SetInterrupted(null);
ProgressText = null;
ProgressValue = 0;
});
// ------------------------------------------------------------------ Outils
private Task RefreshPublishedAsync() => _selectedGame?.Value is { } gameId ? RefreshPublishedCoreAsync(gameId) : Task.CompletedTask;
/// <summary>Version en ligne (manifest du serveur) ; une erreur ici n'empêche pas de continuer.</summary>
private async Task RefreshPublishedCoreAsync(string gameId)
{
try
{
var state = await Context.Session.GetChangelogAsync(gameId);
_publishedVersion = state.ManifestVersion;
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
await RefreshSignatureAsync(gameId);
}
catch (ApiException ex) when (ex.Status == 404)
{
_publishedVersion = "";
PublishedText = "Aucune version en ligne pour l'instant.";
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
{
_publishedVersion = null;
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
_publishedVersion = null;
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
}
UpdateWarnings();
}
private void SetRelease(LocalRelease? release)
{
_release = release;
foreach (var name in new[] { nameof(HasRelease), nameof(VersionText), nameof(NotesTitle), nameof(HasNotesTitle), nameof(FilesText), nameof(ArchiveText) })
OnPropertyChanged(name);
UpdateWarnings();
RefreshCommands();
NotifySignature();
}
private void UpdateWarnings()
{
var warnings = new List<string>();
if (_release is { } release && !string.IsNullOrEmpty(_publishedVersion))
{
if (release.Summary.Version == _publishedVersion)
warnings.Add("Cette version est déjà en ligne : elle sera republiée (fichiers, archive et notes de cette version remplacés).");
else if (VersionUtil.TryParse(release.Summary.Version, out var local) && VersionUtil.TryParse(_publishedVersion, out var online) &&
local < online)
warnings.Add($"Version inférieure à celle en ligne ({_publishedVersion}) : les joueurs reviendront à la version {release.Summary.Version}.");
}
if (_release is { Summary.NotesTitle: null })
warnings.Add("Aucune note pour cette version dans le manifest (celles déjà en ligne pour ce numéro sont conservées).");
Warnings = warnings;
}
private void OnStarted(PublicationState state)
{
SetInterrupted(state);
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
}
private void SetInterrupted(PublicationState? state)
{
_interrupted = state;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(InterruptedText));
AbandonCommand.RaiseCanExecuteChanged();
}
private void OnProgress(UploadProgress progress)
{
if (progress.Step == ReleaseUploader.CommitStep && !_committing)
{
_committing = true; // la mise en ligne ne s'interrompt pas
CancelCommand.RaiseCanExecuteChanged();
}
ProgressValue = progress.Total > 0 ? Math.Clamp((double)progress.Done / progress.Total, 0, 1) : 0;
ProgressText = progress.Total > 0
? $"{progress.Step} — {Format.Bytes(progress.Done)} / {Format.Bytes(progress.Total)}"
: progress.Step;
SpeedText = progress.BytesPerSecond > 0 ? Format.Speed(progress.BytesPerSecond) : null;
}
private void RefreshCommands()
{
ChooseCommand.RaiseCanExecuteChanged();
RecheckCommand.RaiseCanExecuteChanged();
PublishCommand.RaiseCanExecuteChanged();
CancelCommand.RaiseCanExecuteChanged();
AbandonCommand.RaiseCanExecuteChanged();
SignOnlineCommand.RaiseCanExecuteChanged();
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,347 @@
using System.Diagnostics;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>Version prête à publier : dossier produit par ManifestBuilder (le .bat), contrôlé.</summary>
public sealed record LocalRelease(string Folder, string ManifestPath, byte[] ManifestBytes, string ManifestSha256, ManifestSummary Summary)
{
/// <summary>Condensé du contenu signé (phase 7) : fichiers, archive, exécutable… (notes exclues).</summary>
public string ContentDigest { get; } = ContentSigning.ManifestDigest(ManifestBytes);
public string ArchivePath => Path.Combine(Folder, Summary.ArchiveName);
public string ObjectFile(string sha256) =>
Path.Combine(Folder, ManifestCheck.ObjectPath(sha256).Replace('/', Path.DirectorySeparatorChar));
}
/// <summary>Avancement de l'envoi.</summary>
public sealed record UploadProgress(string Step, long Done, long Total, double BytesPerSecond);
/// <summary>Contenu du dossier de publication incorrect (message affiché tel quel).</summary>
public sealed class ReleaseCheckException : InvalidOperationException
{
public ReleaseCheckException(string message) : base(message) { }
}
/// <summary>
/// Publication d'une version : contrôle du dossier local, puis envoi au serveur (manifest, objets absents
/// du serveur, archive) et mise en ligne. Reprise automatique : après une coupure (réseau, serveur
/// occupé, trop de requêtes), l'état est relu sur le serveur et l'envoi continue là où il s'est arrêté ;
/// relancé plus tard avec le même manifest, l'envoi reprend aussi.
/// </summary>
public static class ReleaseUploader
{
private const int MaxAttempts = 8;
/// <summary>Signature réutilisée pour un nouvel essai de mise en ligne pendant ce délai au plus (serveur : ± 10 min).</summary>
private static readonly TimeSpan SignatureReuseLimit = TimeSpan.FromMinutes(5);
/// <summary>Dernière étape (mise en ligne sur le serveur) : elle ne s'interrompt pas.</summary>
public const string CommitStep = "Mise en ligne";
/// <summary>
/// Contrôle du dossier : manifest valide (mêmes règles que le serveur), du bon jeu, archive et fichiers
/// présents avec la bonne taille (empreintes vérifiées pendant l'envoi).
/// </summary>
public static LocalRelease Inspect(string manifestPath, string gameId)
{
var info = new FileInfo(manifestPath);
if (!info.Exists) throw new ReleaseCheckException($"Manifest introuvable : {manifestPath}");
if (info.Length > PublishingLimits.MaxManifestBytes) throw new ReleaseCheckException("Manifest trop volumineux.");
var bytes = File.ReadAllBytes(info.FullName);
var errors = new List<string>();
var summary = ManifestCheck.Check(bytes, gameId, expectedVersion: null, errors)
?? throw new ReleaseCheckException("Ce manifest ne peut pas être publié :\n" + string.Join("\n", errors.Take(8)));
var release = new LocalRelease(info.DirectoryName!, info.FullName, bytes, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), summary);
var archive = new FileInfo(release.ArchivePath);
if (!archive.Exists) errors.Add($"Archive absente : {summary.ArchiveName}");
else if (archive.Length != summary.ArchiveSize) errors.Add($"Archive {summary.ArchiveName} : taille différente de celle du manifest.");
var missing = 0;
foreach (var (sha, size) in summary.Objects)
{
var file = new FileInfo(release.ObjectFile(sha));
if (file.Exists && file.Length == size) continue;
if (++missing <= 5) errors.Add($"{ManifestCheck.ObjectPath(sha)} : {(file.Exists ? "taille différente" : "absent")}.");
}
if (missing > 5) errors.Add($"… et {missing - 5} autre(s) fichier(s) de objects/.");
if (errors.Count > 0)
throw new ReleaseCheckException("Dossier de publication incomplet (relancez le .bat de publication) :\n" + string.Join("\n", errors));
return release;
}
/// <summary>Envoie ce qui manque puis met la version en ligne.</summary>
/// <param name="progress">Avancement ; un <see cref="Progress{T}"/> créé sur le fil de l'interface y est rappelé.</param>
/// <param name="started">Publication ouverte sur le serveur (pour l'abandonner si besoin). Comme <paramref name="progress"/>,
/// passer un <see cref="Progress{T}"/> : l'envoi se poursuit hors du fil de l'interface (ConfigureAwait(false)).</param>
public static async Task<PublicationResult> PublishAsync(AdminSession session, string gameId, LocalRelease release,
IProgress<UploadProgress>? progress, IProgress<PublicationState>? started, CancellationToken ct)
{
var run = new Run(session, release, progress);
var state = await Retry(() => session.StartPublicationAsync(gameId, new PublicationStartRequest
{
Version = release.Summary.Version,
ManifestSha256 = release.ManifestSha256,
ManifestSize = release.ManifestBytes.LongLength
}, ct), ct).ConfigureAwait(false);
started?.Report(state);
var committing = false;
ContentSignature? signature = null;
var signedLocally = DateTime.MinValue;
for (var attempt = 1; ; attempt++)
{
var before = run.Done;
try
{
if (!committing)
{
if (attempt > 1) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
await run.ManifestAsync(state, ct).ConfigureAwait(false);
if (!state.ManifestAccepted) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
run.Plan(state);
await run.ObjectsAsync(state, ct).ConfigureAwait(false);
await run.ArchiveAsync(state, ct).ConfigureAwait(false);
run.Report(CommitStep);
committing = true;
}
// Mise en ligne : ni interrompue (elle se termine sur le serveur de toute façon), ni perdue
// (réponse perdue : le serveur renvoie le même résultat au nouvel essai).
// Signature du contenu (phase 7) juste avant la mise en ligne : le serveur la contrôle (clé de cette
// session, date proche de son heure), puis l'intègre au manifest publié.
// Nouvel essai après plusieurs minutes : signature refaite (le serveur refuse une date trop éloignée).
if (signature is null || DateTime.UtcNow - signedLocally > SignatureReuseLimit)
{
signature = await session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest).ConfigureAwait(false);
signedLocally = DateTime.UtcNow;
}
return await session.CommitPublicationAsync(state.PublicationId, signature, CancellationToken.None).ConfigureAwait(false);
}
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
{
if (run.Done > before) attempt = 1; // l'envoi avance : les essais repartent de zéro
var delay = Backoff(attempt, ex);
if (delay > TimeSpan.Zero) run.Waiting(PauseReason(ex), delay);
await Task.Delay(delay, committing ? CancellationToken.None : ct).ConfigureAwait(false);
}
}
}
// ------------------------------------------------------------------ Envoi
private sealed class Run
{
private readonly AdminSession _session;
private readonly LocalRelease _release;
private readonly IProgress<UploadProgress>? _progress;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private long _total, _done, _sentThisRun;
public long Done => _done;
private string _step = "Préparation";
public Run(AdminSession session, LocalRelease release, IProgress<UploadProgress>? progress)
{
_session = session;
_release = release;
_progress = progress;
}
public async Task ManifestAsync(PublicationState state, CancellationToken ct)
{
if (state.ManifestReceived >= state.ManifestSize) return;
if (_total == 0) _total = state.ManifestSize - state.ManifestReceived;
_step = "Envoi du manifest";
var bytes = _release.ManifestBytes;
for (var offset = state.ManifestReceived; offset < bytes.LongLength;)
{
var length = (int)Math.Min(PublishingLimits.ChunkBytes, bytes.LongLength - offset);
await _session.UploadManifestAsync(state.PublicationId, offset, bytes.AsSpan((int)offset, length).ToArray(), ct).ConfigureAwait(false);
offset += length;
Sent(length);
}
}
/// <summary>Total à envoyer, connu une fois le manifest accepté (calculé une seule fois).</summary>
public void Plan(PublicationState state)
{
if (_total > state.ManifestSize || !state.ManifestAccepted) return;
var objects = state.PendingObjects.Sum(sha => Size(sha) - state.PartialObjects.GetValueOrDefault(sha));
var archive = state.ArchiveComplete ? 0 : state.ArchiveSize - state.ArchiveReceived;
_total = _done + objects + archive;
Report(_step);
}
public async Task ObjectsAsync(PublicationState state, CancellationToken ct)
{
var pending = state.PendingObjects.Select(sha => sha.ToLowerInvariant()).ToList();
if (pending.Count == 0) return;
var count = 0;
void Step() => _step = $"Envoi des fichiers du jeu ({count}/{pending.Count})";
// Petits fichiers : par lots (une requête pour des centaines de fichiers).
var batch = new List<(string Sha, byte[] Data)>();
long batchBytes = 0;
async Task FlushAsync()
{
if (batch.Count == 0) return;
await _session.UploadObjectBatchAsync(state.PublicationId, ObjectBatch.Write(batch), ct).ConfigureAwait(false);
count += batch.Count;
Step();
Sent(batchBytes);
batch.Clear();
batchBytes = 0;
}
foreach (var sha in pending.Where(s => Size(s) <= PublishingLimits.BatchObjectMaxBytes))
{
ct.ThrowIfCancellationRequested();
var data = await File.ReadAllBytesAsync(_release.ObjectFile(sha), ct).ConfigureAwait(false);
if (Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant() != sha) throw Changed(ManifestCheck.ObjectPath(sha));
batch.Add((sha, data));
batchBytes += data.Length;
if (batchBytes >= PublishingLimits.ChunkBytes || batch.Count >= PublishingLimits.MaxObjectsPerBatch) await FlushAsync().ConfigureAwait(false);
}
await FlushAsync().ConfigureAwait(false);
// Gros fichiers : par morceaux, reprise à la position déjà reçue.
foreach (var sha in pending.Where(s => Size(s) > PublishingLimits.BatchObjectMaxBytes))
{
Step();
await FileAsync(_release.ObjectFile(sha), sha, state.PartialObjects.GetValueOrDefault(sha), ManifestCheck.ObjectPath(sha),
(offset, data) => _session.UploadObjectAsync(state.PublicationId, sha, offset, data, ct), ct).ConfigureAwait(false);
count++;
}
}
public async Task ArchiveAsync(PublicationState state, CancellationToken ct)
{
if (state.ArchiveComplete) return;
_step = $"Envoi de l'archive {_release.Summary.ArchiveName}";
await FileAsync(_release.ArchivePath, _release.Summary.ArchiveSha256, state.ArchiveReceived, _release.Summary.ArchiveName,
(offset, data) => _session.UploadArchiveAsync(state.PublicationId, offset, data, ct), ct).ConfigureAwait(false);
}
/// <summary>
/// Envoi d'un fichier à partir de <paramref name="from"/> ; le fichier est relu en entier pour vérifier
/// son empreinte (un fichier modifié depuis la génération est refusé avant la mise en ligne).
/// </summary>
private async Task FileAsync(string path, string sha256, long from, string name, Func<long, byte[], Task<ChunkResult>> upload,
CancellationToken ct)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16, useAsync: true);
var buffer = new byte[PublishingLimits.ChunkBytes];
long offset = 0;
while (true)
{
var read = await ReadFullAsync(stream, buffer, ct).ConfigureAwait(false);
if (read == 0) break;
hash.AppendData(buffer, 0, read);
if (offset + read > from)
{
var skip = (int)Math.Max(0, from - offset);
var chunk = buffer.AsSpan(skip, read - skip).ToArray();
// Dernier morceau : l'empreinte locale est vérifiée AVANT de l'envoyer.
if (offset + read == stream.Length &&
Convert.ToHexString(hash.GetCurrentHash()).ToLowerInvariant() != sha256.ToLowerInvariant())
throw Changed(name);
await upload(offset + skip, chunk).ConfigureAwait(false);
Sent(chunk.Length);
}
offset += read;
}
if (Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant() != sha256.ToLowerInvariant()) throw Changed(name);
}
/// <summary>Pause avant un nouvel essai : affichée (sinon l'envoi semble figé), sans changer d'étape.</summary>
public void Waiting(string reason, TimeSpan delay) =>
_progress?.Report(new UploadProgress($"{_step} — {reason}, reprise dans {Math.Max(1, (int)Math.Ceiling(delay.TotalSeconds))} s…",
Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done), 0));
public void Report(string step)
{
_step = step;
var seconds = _clock.Elapsed.TotalSeconds;
_progress?.Report(new UploadProgress(step, Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done),
seconds > 0.5 ? _sentThisRun / seconds : 0));
}
private void Sent(long bytes)
{
_done += bytes;
_sentThisRun += bytes;
Report(_step);
}
private long Size(string sha) => _release.Summary.Objects.TryGetValue(sha.ToLowerInvariant(), out var size)
? size
: throw new ReleaseCheckException("Le serveur attend un fichier absent de ce manifest : relancez la publication.");
private static ReleaseCheckException Changed(string name) => new(
$"« {name} » a changé depuis la génération de la version (son empreinte ne correspond plus au manifest) : relancez le .bat de publication.");
private static async Task<int> ReadFullAsync(Stream stream, byte[] buffer, CancellationToken ct)
{
var total = 0;
while (total < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(total), ct).ConfigureAwait(false);
if (read == 0) break;
total += read;
}
return total;
}
}
// ------------------------------------------------------------------ Coupures
/// <summary>Coupure réseau, délai dépassé, serveur occupé, trop de requêtes, position décalée : on reprend.</summary>
private static bool IsTransient(ApiException ex) =>
ex.Status is 0 or 429 or 502 or 504 ||
(ex.Status == 503 && ex.Code == ApiErrorCodes.Busy) ||
(ex.Status == 409 && ex.Code == ApiErrorCodes.OffsetMismatch);
/// <summary>
/// Attente avant un nouvel essai. Trop de requêtes : le délai annoncé par le serveur (Retry-After),
/// sinon (limite de nginx, qui se libère en une fraction de seconde) une pause courte qui double à
/// chaque refus consécutif.
/// </summary>
internal static TimeSpan Backoff(int attempt, ApiException ex)
{
if (ex.Code == ApiErrorCodes.OffsetMismatch) return TimeSpan.Zero;
if (ex.Status == 429)
return ex.RetryAfter is { } announced
? TimeSpan.FromSeconds(Math.Clamp(Math.Ceiling(announced.TotalSeconds), 1, 60))
: TimeSpan.FromSeconds(Math.Min(30, 1 << Math.Min(attempt - 1, 5)));
return TimeSpan.FromSeconds(Math.Min(30, 2 * attempt * attempt));
}
private static string PauseReason(ApiException ex) => ex.Status switch
{
429 => "limite de requêtes du serveur atteinte",
0 => "connexion interrompue",
503 => "serveur occupé",
_ => "serveur indisponible"
};
private static async Task<T> Retry<T>(Func<Task<T>> action, CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await action().ConfigureAwait(false);
}
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
{
await Task.Delay(Backoff(attempt, ex), ct).ConfigureAwait(false);
}
}
}
}
@@ -12,14 +12,21 @@ public sealed class AdminLoginService
private readonly LauncherApiClient _client;
private readonly string _clientName;
private readonly IAdminTpm? _tpm;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
/// <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)
/// <param name="rootKeys">
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
/// le nom du fichier (la clé racine reste hors ligne, réservée à tools\sign-keyring.bat et aux mises à jour).
/// </param>
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null,
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null)
{
_client = client;
_clientName = clientName;
_tpm = tpm;
_rootKeys = rootKeys ?? Array.Empty<(string, byte[])>();
}
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
@@ -59,6 +66,14 @@ public sealed class AdminLoginService
key = opened;
}
if (_rootKeys.Any(r => string.Equals(r.KeyId, signer.KeyId, StringComparison.Ordinal) || r.PublicKey.AsSpan().SequenceEqual(signer.PublicKey)))
{
key.Dispose();
throw new InvalidOperationException(
"Cette clé est la clé racine du launcher (signature des mises à jour et du trousseau) : elle ne doit jamais servir " +
"à se connecter. Utilisez votre clé d'administrateur.");
}
try
{
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
@@ -63,6 +63,29 @@ public sealed class AdminSessionGuard : IDisposable
}
}
private int _longOperations;
/// <summary>Opération longue (envoi d'une version) : compte comme une activité jusqu'à sa fin.</summary>
public IDisposable BeginLongOperation()
{
Interlocked.Increment(ref _longOperations);
return new LongOperation(this);
}
private sealed class LongOperation : IDisposable
{
private AdminSessionGuard? _guard;
public LongOperation(AdminSessionGuard guard) => _guard = guard;
public void Dispose()
{
if (Interlocked.Exchange(ref _guard, null) is not { } guard) return;
Interlocked.Decrement(ref guard._longOperations);
guard._lastActivity = guard._time.GetUtcNow(); // 10 minutes à compter de la fin de l'opération
}
}
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
public void NotifyActivity()
{
@@ -76,6 +99,7 @@ public sealed class AdminSessionGuard : IDisposable
{
if (_locked || _disposed) return;
var now = _time.GetUtcNow();
if (Volatile.Read(ref _longOperations) > 0) _lastActivity = now;
if (now >= _connection.LocalExpiresAt)
{
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
@@ -0,0 +1,22 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Signing;
/// <summary>Module « Signature » : page du trousseau et des signatures du contenu (propriétaires).</summary>
public sealed class SigningAdminModule : ILauncherModule
{
public string Id => "signing";
public string DisplayName => "Signature";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
SigningPanelViewModel.PanelId,
"Signature",
"Clés autorisées à signer ce que reçoivent les joueurs (trousseau, signé hors ligne par la clé racine) et état des " +
"signatures des versions en ligne et du catalogue.",
"ShieldKey",
AdminPermissions.AdminsManage,
Order: 55,
context => new SigningPanelViewModel(context)));
}
@@ -0,0 +1,352 @@
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Signing;
/// <summary>
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne.
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
/// </summary>
public sealed class SigningPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "signing";
private SigningState? _state;
private List<KeyringKey> _proposed = new();
private IReadOnlyList<KeyringKeyItem> _keys = Array.Empty<KeyringKeyItem>();
private IReadOnlyList<SignedContentItem> _contents = Array.Empty<SignedContentItem>();
private string? _statusMessage;
public SigningPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
ExportRequestCommand = new AsyncRelayCommand(ExportRequestAsync, () => !IsBusy && _state is not null && NeedsNewKeyring);
PublishSignedCommand = new AsyncRelayCommand(PublishSignedAsync, () => !IsBusy && _state is { KeyringPublishingEnabled: true });
}
// ------------------------------------------------------------------ Trousseau publié
public bool HasKeyring => _state?.Keyring is not null;
public bool PublishingEnabled => _state?.KeyringPublishingEnabled == true;
public bool ShowPublishingDisabled => _state is { KeyringPublishingEnabled: false };
public string? KeyringUrl => _state?.KeyringPublicUrl;
public string KeyringText => _state?.Keyring is { } keyring
? $"Trousseau n° {keyring.Sequence} du {AccountTexts.Date(keyring.IssuedAt)} · {keyring.Keys.Count} clé(s)"
: "Aucun trousseau publié : les launchers en mode « require » refusent tout contenu.";
/// <summary>Signature racine vérifiée avec les clés intégrées à CE launcher.</summary>
public string RootText
{
get
{
if (_state?.Keyring is not { } keyring) return "";
if (Context.RootKeys.Count == 0) return "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : vérification impossible.";
return Keyring.VerifyRoot(keyring, Context.RootKeys, out var error)
? $"Signé par la clé racine {keyring.Signature!.RootKeyId} (vérifié par ce launcher)."
: $"Refusé par ce launcher : {error}.";
}
}
public bool RootValid => _state?.Keyring is { } keyring && Keyring.VerifyRoot(keyring, Context.RootKeys, out _);
public string RequireText => _state?.RequireSignatures == true
? "Le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require)."
: "Le serveur accepte encore les publications non signées (LauncherApi:ContentSigning:Require désactivé).";
// ------------------------------------------------------------------ Trousseau proposé
/// <summary>Clés proposées (appareils actifs autorisés à publier) et différences avec le trousseau publié.</summary>
public IReadOnlyList<KeyringKeyItem> Keys
{
get => _keys;
private set => SetProperty(ref _keys, value);
}
public bool HasChanges => _keys.Any(k => k.Change != KeyChange.None);
/// <summary>Nouveau trousseau à signer : clés modifiées, ou trousseau publié refusé par ce launcher (à remplacer).</summary>
public bool NeedsNewKeyring => _proposed.Count > 0 && (HasChanges || (HasKeyring && !RootValid));
public string ChangesText
{
get
{
if (_state is null) return "";
var added = _keys.Count(k => k.Change == KeyChange.Added);
var removed = _keys.Count(k => k.Change == KeyChange.Removed);
var changed = _keys.Count(k => k.Change == KeyChange.ScopesChanged);
if (added + removed + changed == 0)
return HasKeyring && !RootValid
? "Les clés sont à jour, mais le trousseau publié est refusé par ce launcher : exportez une demande pour le remplacer."
: "Le trousseau publié est à jour.";
var parts = new List<string>();
if (added > 0) parts.Add($"{added} clé(s) à ajouter");
if (removed > 0) parts.Add($"{removed} à retirer");
if (changed > 0) parts.Add($"{changed} autorisation(s) à modifier");
return $"À mettre à jour : {string.Join(", ", parts)}.";
}
}
// ------------------------------------------------------------------ Contenu en ligne
public IReadOnlyList<SignedContentItem> Contents
{
get => _contents;
private set => SetProperty(ref _contents, value);
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand ExportRequestCommand { get; }
public AsyncRelayCommand PublishSignedCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
ExportRequestCommand.RaiseCanExecuteChanged();
PublishSignedCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
private async Task ReloadAsync()
{
var state = await Context.Session.GetSigningStateAsync();
var admins = await Context.Session.ListAdminsAsync();
_state = state;
_proposed = Propose(admins);
Keys = Compare(state.Keyring, _proposed, admins);
Contents = BuildContents(state);
foreach (var name in new[] { nameof(HasKeyring), nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(KeyringUrl),
nameof(KeyringText), nameof(RootText), nameof(RootValid), nameof(RequireText), nameof(HasChanges), nameof(NeedsNewKeyring), nameof(ChangesText) })
OnPropertyChanged(name);
ExportRequestCommand.RaiseCanExecuteChanged();
PublishSignedCommand.RaiseCanExecuteChanged();
}
/// <summary>Appareils actifs des comptes actifs dont le rôle permet de publier une version ou le catalogue.</summary>
internal static List<KeyringKey> Propose(IEnumerable<AdminSummary> admins) =>
admins.Where(a => a.Status == AdminStatus.Active)
.SelectMany(a => a.Keys
.Where(k => k.Status == AdminStatus.Active && k.PublicKey is not null)
.Select(k => new KeyringKey
{
KeyId = k.KeyId,
PublicKey = k.PublicKey!,
Name = Truncate($"{a.DisplayName} — {AccountTexts.KeyLabel(k.Label)}"),
Scopes = SigningScopes.FromPermissions(AdminRoles.PermissionsOf(a.Role)).ToList()
}))
.Where(k => k.Scopes.Count > 0)
.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>Nom public du trousseau : caractères invisibles ou de mise en forme retirés (refusés par les launchers), longueur bornée.</summary>
internal static string Truncate(string name)
{
var clean = new string(name.Where(c => Keyring.IsDisplaySafe(c.ToString())).ToArray()).Trim();
if (clean.Length > Keyring.MaxNameLength) clean = clean[..Keyring.MaxNameLength].TrimEnd();
return clean.Length == 0 ? "Appareil" : clean;
}
private static List<KeyringKeyItem> Compare(KeyringDocument? published, List<KeyringKey> proposed, List<AdminSummary> admins)
{
var current = published?.Keys.ToDictionary(k => k.KeyId, StringComparer.Ordinal) ?? new Dictionary<string, KeyringKey>();
var items = proposed.Select(k =>
{
if (!current.TryGetValue(k.KeyId, out var old)) return new KeyringKeyItem(k, KeyChange.Added);
return SameScopes(old, k) ? new KeyringKeyItem(k, KeyChange.None) : new KeyringKeyItem(k, KeyChange.ScopesChanged);
}).ToList();
foreach (var old in current.Values.Where(k => proposed.All(p => p.KeyId != k.KeyId)))
items.Add(new KeyringKeyItem(old, KeyChange.Removed));
return items;
}
private static bool SameScopes(KeyringKey a, KeyringKey b) =>
a.Scopes.OrderBy(s => s, StringComparer.Ordinal).SequenceEqual(b.Scopes.OrderBy(s => s, StringComparer.Ordinal));
private List<SignedContentItem> BuildContents(SigningState state)
{
var proposed = new KeyringDocument { Keys = _proposed };
var items = state.Manifests.Select(m => new SignedContentItem(
$"Version en ligne de {m.GameId}" + (string.IsNullOrEmpty(m.Version) ? "" : $" ({m.Version})"),
ContentKinds.Manifest, m.GameId, m.Digest, m.Signature, m.SignerName, state.Keyring, proposed,
"Re-signez-la depuis la page « Publier une version » (dossier de cette version, « Signer la version en ligne »).")).ToList();
if (state.Catalog is { } catalog)
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
return items;
}
// ------------------------------------------------------------------ Actions
private async Task ExportRequestAsync()
{
if (_state is not { } state) return;
var unsignedContents = Contents.Where(c => c.WillBreak).Select(c => c.Title).ToList();
if (unsignedContents.Count > 0 &&
!Context.Dialogs.Confirm("Contenu à signer d'abord",
"Avec ce trousseau, les launchers en mode « require » refuseraient :\n• " + string.Join("\n• ", unsignedContents) +
"\n\nSignez-les d'abord avec une clé qui restera dans le trousseau, ou exportez quand même (en mode « warn », " +
"ils seront seulement signalés).", "Exporter quand même"))
return;
var request = new KeyringDocument
{
Sequence = (state.Keyring?.Sequence ?? 0) + 1,
IssuedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + Context.Session.ClockOffset),
Keys = _proposed
};
var path = Context.Dialogs.PickSaveFile("Enregistrer la demande de trousseau", "Trousseau (*.json)|*.json",
$"keyring-request-{request.Sequence}.json", null);
if (path is null) return;
if (await RunAsync(() => File.WriteAllTextAsync(path, Keyring.Serialize(request))))
StatusMessage = $"Demande n° {request.Sequence} enregistrée ({request.Keys.Count} clé(s)). Signez-la avec tools\\sign-keyring.bat " +
"(clé racine, hors ligne), puis « Publier le trousseau signé… ».";
}
private async Task PublishSignedAsync()
{
if (_state is not { } state) return;
StatusMessage = null;
var path = Context.Dialogs.PickOpenFile("Trousseau signé (produit par tools\\sign-keyring.bat)", "Trousseau (*.json)|*.json", null);
if (path is null) return;
KeyringDocument? keyring;
try
{
var info = new FileInfo(path);
if (info.Length > Keyring.MaxBytes) throw new InvalidDataException("fichier trop volumineux");
keyring = Keyring.Parse(await File.ReadAllBytesAsync(path)) ?? throw new InvalidDataException("fichier vide");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
ErrorMessage = $"Trousseau illisible : {ex.Message}";
return;
}
// Mêmes contrôles que les launchers : un trousseau qu'ils refuseraient n'est pas publié.
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors))
{
ErrorMessage = "Trousseau invalide : " + string.Join(" ", errors.Take(4));
return;
}
if (Context.RootKeys.Count == 0)
{
ErrorMessage = "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : impossible de vérifier le trousseau.";
return;
}
if (!Keyring.VerifyRoot(keyring, Context.RootKeys, out var rootError))
{
ErrorMessage = $"Trousseau refusé : {rootError}. Signez la demande avec tools\\sign-keyring.bat et la clé racine du launcher.";
return;
}
if (state.Keyring is { } current && keyring.Sequence <= current.Sequence)
{
ErrorMessage = $"Trousseau n° {keyring.Sequence} : le n° {current.Sequence} est déjà publié. Exportez une nouvelle demande.";
return;
}
var names = keyring.Keys.Select(k => $"{k.Name} ({string.Join(", ", k.Scopes.Select(SigningScopes.DisplayName))})");
if (!Context.Dialogs.Confirm($"Publier le trousseau n° {keyring.Sequence} ?",
"Les launchers ne feront plus confiance qu'à ces clés :\n• " + string.Join("\n• ", names), "Publier"))
return;
if (await RunAsync(async () =>
{
await Context.Session.PublishKeyringAsync(keyring);
await ReloadAsync();
}))
StatusMessage = $"Trousseau n° {keyring.Sequence} publié. Les launchers l'utilisent dès leur prochain démarrage.";
}
}
public enum KeyChange
{
None,
Added,
Removed,
ScopesChanged
}
/// <summary>Clé du trousseau proposé (ou retirée du trousseau publié).</summary>
public sealed class KeyringKeyItem
{
internal KeyringKeyItem(KeyringKey key, KeyChange change)
{
Key = key;
Change = change;
ScopesText = string.Join(", ", key.Scopes.Select(SigningScopes.DisplayName));
ChangeText = change switch
{
KeyChange.Added => "À ajouter",
KeyChange.Removed => "À retirer",
KeyChange.ScopesChanged => "Autorisations modifiées",
_ => "Dans le trousseau"
};
}
public KeyringKey Key { get; }
public KeyChange Change { get; }
public string Name => Key.Name;
public string KeyId => Key.KeyId;
public string ScopesText { get; }
public string ChangeText { get; }
public bool IsAdded => Change == KeyChange.Added;
public bool IsRemoved => Change == KeyChange.Removed;
public bool IsChanged => Change == KeyChange.ScopesChanged;
}
/// <summary>Contenu en ligne et sa signature.</summary>
public sealed class SignedContentItem
{
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
KeyringDocument? published, KeyringDocument proposed, string howToSign)
{
Title = title;
if (string.IsNullOrEmpty(digest))
{
Text = "Rien n'est publié.";
IsValid = true;
return;
}
var signer = signature is null ? null : $"{signerName ?? $"clé {signature.KeyId}"}, le {AccountTexts.Date(signature.SignedAt)}";
var now = published is null ? ContentCheck.Fail("aucun trousseau publié") : ContentSigning.Verify(kind, id, digest, signature, published);
var next = ContentSigning.Verify(kind, id, digest, signature, proposed);
IsValid = now.Valid;
WillBreak = !next.Valid;
Text = signature is null
? $"Non signé. {howToSign}"
: now.Valid
? $"Signé par {signer} : valide pour les launchers."
: $"Signé par {signer}, mais refusé par les launchers : {now.Reason}. {howToSign}";
if (signature is not null && now.Valid && !next.Valid)
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
}
public string Title { get; }
public string Text { get; }
/// <summary>Accepté par les launchers avec le trousseau publié.</summary>
public bool IsValid { get; }
/// <summary>Serait refusé avec le trousseau proposé.</summary>
public bool WillBreak { get; }
}
@@ -35,6 +35,12 @@ public sealed class AdminEnvironment
public TimeProvider Time { get; init; } = TimeProvider.System;
/// <summary>
/// Clés racine intégrées à l'exe (update-signing.pub) : vérification d'un trousseau signé avant sa publication
/// (page « Signature »). Vide : aucune clé intégrée.
/// </summary>
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; init; } = Array.Empty<(string, byte[])>();
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
public IAdminTpm? Tpm { get; init; }
@@ -125,8 +125,11 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public IReadOnlyList<string> Themes => _shell.Environment.Themes;
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
public void Navigate(string panelId)
{
if (PanelItems.FirstOrDefault(p => string.Equals(p.Descriptor.Id, panelId, StringComparison.OrdinalIgnoreCase)) is { } item)
@@ -1,4 +1,5 @@
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
@@ -28,8 +29,18 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
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);
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
}
/// <summary>Pages « Administrateurs » et « Mes appareils » présentes (modules déclarés, droits).</summary>
public bool CanOpenAdmins => Context.Panels.Any(p => p.Id == AdminsPanelViewModel.PanelId);
public bool CanOpenDevices => Context.Panels.Any(p => p.Id == MyDevicesPanelViewModel.PanelId);
public RelayCommand OpenAdminsCommand { get; }
public RelayCommand OpenDevicesCommand { get; }
public string DisplayName => Context.Session.Profile.DisplayName;
public string Initials => AdminTexts.Initials(DisplayName);
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
@@ -175,7 +175,7 @@ public sealed class LoginViewModel : ObservableObject, IDisposable
try
{
var environment = _shell.Environment;
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm).LoginAsync(key.Path, passphrase);
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm, environment.RootKeys).LoginAsync(key.Path, passphrase);
}
catch (Exception ex)
{
@@ -27,6 +27,9 @@ public interface IAdminContext
string LauncherVersion { get; }
/// <summary>Clés racine intégrées à l'exe (vérification du trousseau avant sa publication).</summary>
IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; }
/// <summary>Pages visibles pour cet administrateur.</summary>
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
@@ -34,4 +37,11 @@ public interface IAdminContext
/// <summary>La session n'est plus valable : retour à l'écran de connexion avec ce message.</summary>
void ReportSessionLost(string reason);
/// <summary>
/// Opération longue en cours (envoi d'une version…) : tant que l'objet renvoyé n'est pas libéré,
/// elle compte comme une activité (pas de verrouillage pour inactivité, session prolongée). Le
/// verrouillage manuel, celui de Windows et la durée maximale de la session restent appliqués.
/// </summary>
IDisposable BeginLongOperation();
}
+30 -2
View File
@@ -7,6 +7,7 @@ public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
private readonly OwnerThread _owner = OwnerThread.Current();
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
@@ -23,7 +24,7 @@ public sealed class RelayCommand : ICommand
if (CanExecute(parameter)) _execute();
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
public void RaiseCanExecuteChanged() => _owner.Run(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}
/// <summary>Commande asynchrone : désactivée pendant son exécution (pas de double clic).</summary>
@@ -31,6 +32,7 @@ public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private readonly OwnerThread _owner = OwnerThread.Current();
private bool _isRunning;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
@@ -67,5 +69,31 @@ public sealed class AsyncRelayCommand : ICommand
}
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
public void RaiseCanExecuteChanged() => _owner.Run(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}
/// <summary>
/// Fil qui a créé la commande (celui de l'interface sous WPF). Un bouton n'accepte d'être notifié que
/// depuis ce fil : appelée d'ailleurs (suite d'un ConfigureAwait(false)…), la notification y est renvoyée
/// au lieu de lever « un autre thread en est propriétaire ».
/// </summary>
internal readonly struct OwnerThread
{
private readonly int _threadId;
private readonly SynchronizationContext? _context;
private OwnerThread(int threadId, SynchronizationContext? context)
{
_threadId = threadId;
_context = context;
}
public static OwnerThread Current() => new(Environment.CurrentManagedThreadId, SynchronizationContext.Current);
public void Run(Action action)
{
// Même fil, ou aucun fil d'interface connu (tests, services) : exécution directe.
if (_context is null || Environment.CurrentManagedThreadId == _threadId) action();
else _context.Post(static state => ((Action)state!)(), action);
}
}
+67 -3
View File
@@ -10,11 +10,16 @@ using GameLauncher.Core.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.ViewModels;
@@ -51,6 +56,7 @@ public partial class App : Application, ILauncherRestartHost
private LauncherUpdateService? _updates;
private AdminWindowService? _admin;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
private Task<CatalogDocument?>? _catalogFetch;
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -156,6 +162,10 @@ public partial class App : Application, ILauncherRestartHost
}
}
// Signatures du contenu (phase 7) : trousseau téléchargé dès maintenant, en parallèle du catalogue.
_trust = CreateContentTrust(catalog);
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
@@ -291,7 +301,7 @@ public partial class App : Application, ILauncherRestartHost
var settingsPath = catalog.GetGameSettingsPath(config); // GameLauncher\Games\<GameId>\settings.json
var settings = LauncherSettings.Load(settingsPath);
var installer = new GameInstaller(_http!);
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl));
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl), _trust, config.GameId);
// Si le launcher a été déplacé depuis l'installation du jeu, le bouton « Désinstaller »
// de Windows doit pointer vers son nouvel emplacement. (Pas en mode --capture.)
@@ -368,7 +378,7 @@ public partial class App : Application, ILauncherRestartHost
if (url is null || _http is null) return;
try
{
_catalogService = new CatalogService(_http, catalog.DataDirectory, new Uri(url));
_catalogService = new CatalogService(_http, catalog.DataDirectory, new Uri(url), _trust);
if (_catalogService.LoadCached() is { } cached) ApplyCatalog(catalog, cached, args);
if (download) _catalogFetch = _catalogService.FetchAsync();
}
@@ -450,6 +460,56 @@ public partial class App : Application, ILauncherRestartHost
}
}
// ------------------------------------------------------------------ Signatures du contenu (phase 7)
/// <summary>
/// Vérification des signatures du manifest et du catalogue (« keyringUrl », « contentSigning »), avec la ou les
/// clés racine intégrées à l'exe (Security\update-signing.pub). Null : mode « off ».
/// </summary>
private ContentTrustService? CreateContentTrust(LauncherCatalog catalog)
{
var (keyringUrl, mode, minSequence) = ResolveContentSigning(catalog);
if (mode == ContentSigningMode.Off) return null;
var roots = LauncherUpdateService.LoadRootKeys();
if (roots.Count == 0)
Log.Error("Signatures du contenu : aucune clé racine intégrée (Security\\update-signing.pub) : aucun trousseau ne peut être vérifié.");
Log.Info($"Signatures du contenu : mode « {mode.ToString().ToLowerInvariant()} », trousseau {keyringUrl ?? "non configuré"}" +
(minSequence > 0 ? $" (n° {minSequence} au minimum)." : "."));
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUrl is null ? null : new Uri(keyringUrl), roots, mode,
minSequence);
}
/// <summary>
/// « keyringUrl », « contentSigning » et « keyringMinSequence ». Comme apiUrl : en Release, seulement ceux du
/// launcher.json intégré à l'exécutable (un fichier posé à côté ne peut ni désactiver la vérification, ni désigner
/// un autre trousseau, ni abaisser le numéro minimal).
/// </summary>
private static (string? KeyringUrl, ContentSigningMode Mode, long MinSequence) ResolveContentSigning(LauncherCatalog catalog)
{
#if DEBUG
if (catalog.KeyringUrlError is not null) Log.Warn(catalog.KeyringUrlError);
if (catalog.ContentSigningError is not null) Log.Warn(catalog.ContentSigningError);
return (catalog.KeyringUrl, catalog.ContentSigning, catalog.KeyringMinSequence);
#else
try
{
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
if ((catalog.KeyringUrl is not null && !string.Equals(catalog.KeyringUrl, integrated?.KeyringUrl, StringComparison.Ordinal)) ||
(catalog.KeyringUrl is not null && catalog.ContentSigning != integrated?.ContentSigning))
Log.Warn("« keyringUrl » / « contentSigning » du launcher.json externe ignorés : seuls ceux intégrés à l'exécutable sont utilisés.");
if (integrated?.KeyringUrlError is { } urlError) Log.Warn(urlError);
if (integrated?.ContentSigningError is { } modeError) Log.Warn(modeError);
return integrated is null ? (null, ContentSigningMode.Off, 0) : (integrated.KeyringUrl, integrated.ContentSigning, integrated.KeyringMinSequence);
}
catch (Exception ex)
{
Log.Error("Signatures du contenu (launcher.json intégré)", ex);
return (null, ContentSigningMode.Off, 0);
}
#endif
}
/// <summary>
/// Adresse du catalogue (« catalogUrl »). Comme apiUrl : en Release, seulement celle du
/// launcher.json intégré à l'exécutable ; en Debug, celle de la configuration utilisée.
@@ -506,7 +566,11 @@ public partial class App : Application, ILauncherRestartHost
{
new AdminModule(),
new CatalogAdminModule(),
new ChangelogAdminModule()
new ChangelogAdminModule(),
new PublishAdminModule(),
new AccountsAdminModule(),
new SigningAdminModule(),
new AuditAdminModule()
});
/// <summary>
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>1.3.0</Version>
<Version>2.0.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -458,6 +458,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
// ------------------------------------------------------------------ Utilitaires
/// <summary>Clés racine (identifiant, SPKI) : trousseau du contenu signé (phase 7), vérifié avec les mêmes clés.</summary>
internal static IReadOnlyList<(string KeyId, byte[] PublicKey)> LoadRootKeys() =>
LoadTrustedKeys().Select(k => (k.KeyId, k.SubjectPublicKeyInfo)).ToList();
private static IReadOnlyList<UpdateSignature.TrustedKey> LoadTrustedKeys()
{
try
@@ -84,7 +84,8 @@ public sealed class AdminWindowService : IDisposable
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
LauncherVersion = _launcherVersion,
RootKeys = LauncherUpdateService.LoadRootKeys()
});
window = new AdminWindow(shell);
if (_owner.IsVisible) window.Owner = _owner;
+805 -6
View File
@@ -3,7 +3,11 @@
xmlns:ui="clr-namespace:GameLauncher.Controls"
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin"
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin"
xmlns:achl="clr-namespace:GameLauncher.Modules.Admin.Changelog;assembly=GameLauncher.Modules.Admin">
xmlns:achl="clr-namespace:GameLauncher.Modules.Admin.Changelog;assembly=GameLauncher.Modules.Admin"
xmlns:apub="clr-namespace:GameLauncher.Modules.Admin.Publishing;assembly=GameLauncher.Modules.Admin"
xmlns:aacc="clr-namespace:GameLauncher.Modules.Admin.Accounts;assembly=GameLauncher.Modules.Admin"
xmlns:aaud="clr-namespace:GameLauncher.Modules.Admin.Audit;assembly=GameLauncher.Modules.Admin"
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin">
<!--
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
@@ -302,7 +306,10 @@
</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" />
<TextBlock Text="{Binding SessionText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0" />
<Button Style="{StaticResource LinkButtonStyle}" Content="Mes appareils" HorizontalAlignment="Left" Margin="0,6,0,12"
FontSize="12" Command="{Binding OpenDevicesCommand}"
Visibility="{Binding CanOpenDevices, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Permissions}" Focusable="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -335,10 +342,15 @@
<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>
<DockPanel Grid.Column="1" Margin="14,0,0,0" VerticalAlignment="Center">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Gérer" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.AccountGroup}" Command="{Binding OpenAdminsCommand}"
Visibility="{Binding CanOpenAdmins, Converter={StaticResource BoolToVisibility}}" />
<StackPanel>
<TextBlock Text="Administrateurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock x:Name="PendingText" Text="{Binding PendingText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
</Grid>
</Border>
@@ -1010,4 +1022,791 @@
</Border>
</DataTemplate>
<!-- ============================================================ Page « Publier une version » -->
<DataTemplate DataType="{x:Type apub:PublishPanelViewModel}">
<StackPanel>
<!-- Erreur (contrôle du dossier, refus du serveur…) -->
<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}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Version publiée -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Envoi interrompu : reprise ou abandon -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasInterrupted, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Abandonner" Margin="12,0,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource DangerBrush}"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding AbandonCommand}" />
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Pause}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding InterruptedText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Jeu (plusieurs jeux déclarés dans launcher.json) -->
<DockPanel Margin="0,0,0,14" Visibility="{Binding HasSeveralGames, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="340" HorizontalAlignment="Left"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding CanEdit}" />
</DockPanel>
<!-- Dossier de publication -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}" VerticalAlignment="Top">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.PackageVariant}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Dossier de publication" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding PublishedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<!-- Signature de la version en ligne (phase 7) -->
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
Visibility="{Binding HasOnlineSignatureText, Converter={StaticResource BoolToVisibility}}">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}}" />
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding OnlineSignatureText}" Style="{StaticResource AdminHintStyle}" Margin="8,0,0,0" MaxWidth="600" />
</StackPanel>
<DockPanel Margin="0,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="0,0,6,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" ToolTip="Contrôler de nouveau le dossier"
Command="{Binding RecheckCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Choisir le manifest.json…"
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding ChooseCommand}" />
</StackPanel>
<TextBlock VerticalAlignment="Center" FontSize="12.5" TextTrimming="CharacterEllipsis" ToolTip="{Binding ManifestPath}"
Text="{Binding ManifestPath, TargetNullValue='Choisissez le manifest.json produit par le .bat de publication (dossier --output).'}"
Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
<!-- Version en ligne identique au dossier choisi : signature sans renvoi -->
<DockPanel Margin="0,12,0,0" Visibility="{Binding CanSignOnline, Converter={StaticResource BoolToVisibility}}">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Signer la version en ligne" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ShieldCheck}" Command="{Binding SignOnlineCommand}"
ToolTip="Ajoute votre signature au manifest en ligne (le serveur vérifie que son contenu est celui de ce dossier)" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Ce dossier correspond exactement à la version en ligne : vous pouvez la signer sans la renvoyer." />
</DockPanel>
<TextBlock Text="{Binding OnlineMismatchText}" Style="{StaticResource AdminHintStyle}" Margin="0,10,0,0"
Foreground="{DynamicResource WarningBrush}"
Visibility="{Binding OnlineMismatchText, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
</Grid>
</Border>
<!-- Version à publier (dossier contrôlé) -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasRelease, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Right" Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<Path Width="11" Height="11" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.Check}"
VerticalAlignment="Center" />
<TextBlock Text="Dossier vérifié" FontSize="11.5" Margin="6,0,0,0" />
</StackPanel>
</Border>
<TextBlock Text="{Binding VersionText}" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="{Binding NotesTitle}" FontSize="13" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding HasNotesTitle, Converter={StaticResource BoolToVisibility}}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Fichiers" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Column="1" Text="{Binding FilesText}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="1" Text="Archive" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ArchiveText}" Style="{StaticResource AdminValueStyle}" />
</Grid>
<ItemsControl ItemsSource="{Binding Warnings}" Focusable="False" Margin="0,4,0,0"
Visibility="{Binding HasWarnings, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,2,0,0" />
<TextBlock Text="{Binding}" Margin="8,0,0,0" FontSize="12.5" TextWrapping="Wrap" MaxWidth="600" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Avancement de l'envoi -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding ProgressText, Converter={StaticResource StringToVisibility}}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Interrompre" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Pause}" Command="{Binding CancelCommand}"
ToolTip="Arrête l'envoi ; « Publier » le reprendra là où il s'est arrêté"
Visibility="{Binding IsPublishing, Converter={StaticResource BoolToVisibility}}" />
<TextBlock DockPanel.Dock="Right" Text="{Binding SpeedText}" FontSize="12" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding ProgressText}" FontSize="12.5" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Margin="0,12,0,0" Maximum="1" Value="{Binding ProgressValue, Mode=OneWay}" />
</StackPanel>
</Border>
<!-- Publication -->
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,8,0,14" />
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Publier"
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishCommand}"
ToolTip="Envoie les fichiers absents du serveur puis met la version en ligne (requêtes signées par votre clé)" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" Margin="0,0,16,0"
Text="L'envoi peut être interrompu et repris. Le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding ShowBusyBar, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- ============================================================ Pages « Mes appareils » et « Administrateurs » -->
<!-- Code d'une invitation tout juste créée : affiché une seule fois (le serveur n'en garde que l'empreinte) -->
<DataTemplate DataType="{x:Type aacc:InvitationCodeViewModel}">
<Border Style="{StaticResource AdminCardStyle}" BorderBrush="{DynamicResource Accent2Brush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.TicketAccount}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="{Binding Title}" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding Instructions}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,12" />
<Border CornerRadius="12" Padding="16,12" BorderThickness="1" Background="{DynamicResource SurfaceAltBrush}"
BorderBrush="{DynamicResource BorderStrongBrush}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Copier" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyCommand}" />
<TextBlock Text="{Binding Code}" Style="{StaticResource FingerprintTextStyle}" FontSize="19" TextWrapping="Wrap"
VerticalAlignment="Center" />
</DockPanel>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0" Visibility="{Binding Copied, Converter={StaticResource BoolToVisibility}}">
<Path Width="12" Height="12" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.Check}"
VerticalAlignment="Center" />
<TextBlock Text="Copié dans le presse-papiers." FontSize="12" Margin="6,0,0,0" Foreground="{DynamicResource SuccessBrush}" />
</StackPanel>
<DockPanel Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="J'ai transmis le code" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding DismissCommand}"
ToolTip="Le code disparaît de l'écran : il ne pourra plus être affiché" />
<TextBlock Text="{Binding ExpiresText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
<!-- Appareil (clé) d'un administrateur -->
<DataTemplate DataType="{x:Type aacc:AdminKeyItem}">
<Border x:Name="Bd" CornerRadius="12" Padding="14,10" Margin="0,0,0,8" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Révoquer" Margin="12,0,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource DangerBrush}" ui:IconAssist.Icon="{StaticResource Icon.Delete}"
Command="{Binding RevokeCommand}" ToolTip="Cet appareil ne pourra plus se connecter (définitif)"
Visibility="{Binding CanRevoke, Converter={StaticResource BoolToVisibility}}" />
<Border Width="32" Height="32" CornerRadius="10" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
<Path x:Name="DeviceIcon" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.Laptop}" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<WrapPanel>
<TextBlock Text="{Binding Label}" FontSize="13" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,10,0" />
<Border Style="{StaticResource AdminChipStyle}" Margin="0" Padding="8,1" VerticalAlignment="Center">
<TextBlock x:Name="StatusText" Text="{Binding StatusText}" FontSize="10.5" FontWeight="SemiBold"
Foreground="{DynamicResource SuccessBrush}" />
</Border>
</WrapPanel>
<TextBlock Text="{Binding Fingerprint}" FontFamily="Cascadia Mono, Consolas, Courier New" FontSize="13" Margin="0,4,0,0"
ToolTip="Empreinte de la clé de cet appareil"
Visibility="{Binding Fingerprint, Converter={StaticResource StringToVisibility}}" />
<TextBlock Text="{Binding FingerprintText}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Visibility="{Binding IsPending, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,3,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
<!-- Approbation : empreinte dictée par la personne -->
<StackPanel Margin="44,12,0,0" Visibility="{Binding CanApprove, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Empreinte dictée par la personne (5 groupes de 4 caractères)" Style="{StaticResource AdminFieldLabelStyle}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Approuver" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.ShieldCheck}" Command="{Binding ApproveCommand}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="64" FontFamily="Cascadia Mono, Consolas, Courier New"
Text="{Binding FingerprintInput, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Exemple : AB12 CD34 EF56 7890 1A2B (espaces facultatifs)" />
</DockPanel>
</StackPanel>
<TextBlock Text="{Binding ApproveHint}" Style="{StaticResource AdminHintStyle}" Margin="44,8,0,0"
Visibility="{Binding HasApproveHint, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsPending}" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsRevoked}" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.6" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsCurrentSession}" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource Accent2Brush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Invitation en cours -->
<DataTemplate DataType="{x:Type aacc:InvitationItem}">
<DockPanel Margin="0,0,0,10">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding DeleteCommand}"
ToolTip="Supprimer l'invitation : son code ne pourra plus être utilisé"
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}" />
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.TicketAccount}"
VerticalAlignment="Top" Margin="0,2,0,0" />
<StackPanel Margin="12,0,0,0">
<TextBlock Text="{Binding Title}" FontSize="12.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</DataTemplate>
<!-- Page « Mes appareils » -->
<DataTemplate DataType="{x:Type aacc:MyDevicesPanelViewModel}">
<StackPanel>
<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}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
Visibility="{Binding HasCreatedInvitation, Converter={StaticResource BoolToVisibility}}" />
<!-- Appareils -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel Margin="0,0,0,14">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Ajouter un appareil" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.KeyPlus}" Command="{Binding AddDeviceCommand}"
ToolTip="Crée une invitation « nouvel appareil » à utiliser sur l'autre PC" />
<Border Style="{StaticResource AdminAvatarStyle}">
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock FontSize="14" FontWeight="SemiBold">
<Run Text="{Binding DisplayName, Mode=OneWay}" />
<Run Text="·" Foreground="{DynamicResource TextMutedBrush}" />
<Run Text="{Binding RoleText, Mode=OneWay}" Foreground="{DynamicResource Accent2Brush}" />
</TextBlock>
<TextBlock Text="Chaque PC a sa propre clé : en révoquer un n'affecte pas les autres."
Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0" />
</StackPanel>
</DockPanel>
<ItemsControl ItemsSource="{Binding Devices}" Focusable="False" IsEnabled="{Binding CanEdit}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="Appareil perdu ou volé : demandez à un propriétaire de le révoquer (page « Administrateurs »)."
Visibility="{Binding CanRevokeDevices, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
</StackPanel>
</Border>
<!-- Invitations « nouvel appareil » -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasInvitations, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Invitations en cours" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Codes pas encore utilisés sur un nouvel appareil (valables 24 h)." Style="{StaticResource AdminHintStyle}"
Margin="0,3,0,12" />
<ItemsControl ItemsSource="{Binding Invitations}" Focusable="False" IsEnabled="{Binding CanEdit}" />
</StackPanel>
</Border>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Nouveau PC : « Ajouter un appareil », code saisi sur le nouveau PC (« J'ai une invitation »), puis approbation ici avec l'empreinte qu'il affiche." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- Compte d'un administrateur (page « Administrateurs ») -->
<DataTemplate DataType="{x:Type aacc:AdminAccountItem}">
<Border x:Name="Card" Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel>
<!-- Rôle et révocation -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0" VerticalAlignment="Center"
Visibility="{Binding CanChangeRole, Converter={StaticResource BoolToVisibility}}">
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="170" ItemsSource="{Binding RoleChoices}"
SelectedItem="{Binding SelectedRole, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Rôle" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Appliquer" Margin="8,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding ChangeRoleCommand}"
Visibility="{Binding RoleChanged, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource IconButtonStyle}" Width="32" Height="32" Padding="8" Margin="8,0,0,0"
Foreground="{DynamicResource DangerBrush}" ui:IconAssist.Icon="{StaticResource Icon.AccountRemove}"
Command="{Binding RevokeCommand}" ToolTip="Révoquer ce compte (sessions fermées, appareils révoqués ; définitif)" />
</StackPanel>
<Border Style="{StaticResource AdminAvatarStyle}" VerticalAlignment="Top">
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<WrapPanel>
<TextBlock Text="{Binding DisplayName}" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,10,0" />
<Border Style="{StaticResource AdminChipStyle}" Margin="0,0,6,0" Padding="8,1" VerticalAlignment="Center">
<TextBlock Text="{Binding RoleText}" FontSize="10.5" FontWeight="SemiBold" Foreground="{DynamicResource Accent2Brush}" />
</Border>
<Border x:Name="StatusChip" Style="{StaticResource AdminChipStyle}" Margin="0,0,6,0" Padding="8,1" VerticalAlignment="Center"
Visibility="Collapsed">
<TextBlock x:Name="StatusText" Text="{Binding StatusText}" FontSize="10.5" FontWeight="SemiBold" />
</Border>
<Border Style="{StaticResource AdminChipStyle}" Margin="0" Padding="8,1" VerticalAlignment="Center"
Visibility="{Binding IsYou, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Vous" FontSize="10.5" FontWeight="SemiBold" />
</Border>
</WrapPanel>
<TextBlock Text="{Binding CreatedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" Margin="0,14,0,0" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsPending}" Value="True">
<Setter TargetName="Card" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="StatusChip" Property="Visibility" Value="Visible" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsRevoked}" Value="True">
<Setter TargetName="Card" Property="Opacity" Value="0.6" />
<Setter TargetName="StatusChip" Property="Visibility" Value="Visible" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Page « Administrateurs » -->
<DataTemplate DataType="{x:Type aacc:AdminsPanelViewModel}">
<StackPanel>
<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}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
Visibility="{Binding HasCreatedInvitation, Converter={StaticResource BoolToVisibility}}" />
<!-- Nouvelle invitation -->
<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.EmailOutline}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Inviter un administrateur" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Le code créé est à transmettre à la personne ; son appareil apparaîtra ensuite ci-dessous, à approuver avec l'empreinte qu'elle vous dictera." />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="190" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Nom" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="64" IsEnabled="{Binding CanEdit}"
Text="{Binding NewName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Rôle" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding RoleChoices}" IsEnabled="{Binding CanEdit}"
SelectedItem="{Binding NewRole, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource AccentButtonStyle}" Content="Créer l'invitation" Margin="10,0,0,0"
VerticalAlignment="Bottom" ui:IconAssist.Icon="{StaticResource Icon.TicketAccount}" Command="{Binding InviteCommand}" />
</Grid>
<TextBlock Text="{Binding NewRoleHint}" Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0" />
</StackPanel>
</Grid>
</Border>
<!-- Invitations en cours -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasInvitations, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Invitations en cours" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Codes pas encore utilisés (valables 24 h). Supprimez celles qui n'ont plus lieu d'être."
Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12" />
<ItemsControl ItemsSource="{Binding Invitations}" Focusable="False" IsEnabled="{Binding CanEdit}" />
</StackPanel>
</Border>
<!-- Comptes -->
<DockPanel Margin="2,4,0,12">
<CheckBox DockPanel.Dock="Right" Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding ShowRevoked, Mode=TwoWay}"
FontSize="12.5" VerticalAlignment="Center">
<TextBlock Text="{Binding RevokedText}" />
</CheckBox>
<TextBlock VerticalAlignment="Center">
<Run Text="Comptes" FontSize="15" FontWeight="SemiBold" />
<Run Text="{Binding SummaryText, Mode=OneWay}" FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
</TextBlock>
</DockPanel>
<ItemsControl ItemsSource="{Binding Admins}" Focusable="False" IsEnabled="{Binding CanEdit}" />
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Toutes ces actions sont signées par votre clé et inscrites au journal d'audit. Il reste toujours au moins un propriétaire actif." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Journal d'audit » -->
<DataTemplate DataType="{x:Type aaud:AuditItem}">
<Border x:Name="Row" CornerRadius="10" Padding="12,8" Margin="0,0,0,6" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Path x:Name="ResultIcon" Width="15" Height="15" Stretch="Uniform" VerticalAlignment="Top" Margin="0,2,0,0"
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
<StackPanel Grid.Column="1" Margin="12,0,12,0">
<TextBlock TextWrapping="Wrap">
<Run Text="{Binding ActionText, Mode=OneWay}" FontSize="12.5" FontWeight="SemiBold" />
<Run Text="·" Foreground="{DynamicResource TextMutedBrush}" />
<Run Text="{Binding ActorText, Mode=OneWay}" FontSize="12.5" />
</TextBlock>
<TextBlock Text="{Binding DetailText}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" Visibility="{Binding HasDetail, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding OriginText}" FontSize="11" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" Visibility="{Binding OriginText, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Right">
<TextBlock Text="{Binding TimeText}" FontSize="11.5" HorizontalAlignment="Right" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock x:Name="ResultText" Text="{Binding ResultText}" FontSize="11" FontWeight="SemiBold" HorizontalAlignment="Right"
Margin="0,2,0,0" Foreground="{DynamicResource SuccessBrush}" ToolTip="{Binding ActionCode}" />
</StackPanel>
</Grid>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsDenied}" Value="True">
<Setter TargetName="ResultIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="ResultIcon" Property="Data" Value="{DynamicResource Icon.Alert}" />
<Setter TargetName="ResultText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsFailed}" Value="True">
<Setter TargetName="ResultIcon" Property="Fill" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="ResultIcon" Property="Data" Value="{DynamicResource Icon.AlertCircle}" />
<Setter TargetName="ResultText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<DataTemplate DataType="{x:Type aaud:AuditPanelViewModel}">
<StackPanel>
<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}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Filtres -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="200" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Rechercher (nom, adresse IP, clé, version…)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="100"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Catégorie" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Categories}"
SelectedItem="{Binding Category, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="10,0,0,0">
<TextBlock Text="Résultat" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Results}"
SelectedItem="{Binding Result, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<Button Grid.Column="3" Style="{StaticResource IconButtonStyle}" Width="34" Height="34" Padding="9" Margin="8,0,0,0"
VerticalAlignment="Bottom" ui:IconAssist.Icon="{StaticResource Icon.Close}" ToolTip="Effacer les filtres"
Command="{Binding ClearFiltersCommand}" />
</Grid>
<DockPanel Margin="0,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource SmallButtonStyle}" Content="Exporter…" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding ExportCommand}"
ToolTip="Enregistre les entrées filtrées dans un fichier CSV (Excel)" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
</StackPanel>
<TextBlock Text="{Binding SummaryText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Border>
<!-- Entrées -->
<TextBlock Text="{Binding EmptyText}" Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,4,0,16"
Visibility="{Binding EmptyText, Converter={StaticResource StringToVisibility}}" />
<ItemsControl ItemsSource="{Binding Entries}" Focusable="False" />
<Border Style="{StaticResource AdminInfoBannerStyle}" Margin="0,8,0,8" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding IsTruncated, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding TruncatedText}" TextWrapping="Wrap" FontSize="12.5" />
</Border>
<DockPanel Margin="0,8,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Charger des entrées plus anciennes" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.History}" Command="{Binding LoadMoreCommand}"
Visibility="{Binding CanLoadMore, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding LastRefresh}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Signature » (phase 7) -->
<!-- Clé du trousseau proposé -->
<DataTemplate DataType="{x:Type asig:KeyringKeyItem}">
<Border x:Name="Row" CornerRadius="10" Padding="12,8" Margin="0,0,0,6" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<DockPanel>
<Border DockPanel.Dock="Right" Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" Padding="8,1" VerticalAlignment="Center">
<TextBlock x:Name="ChangeText" Text="{Binding ChangeText}" FontSize="10.5" FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}" />
</Border>
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.KeyVariant}"
VerticalAlignment="Top" Margin="0,2,0,0" />
<StackPanel Margin="12,0,0,0">
<TextBlock Text="{Binding Name}" FontSize="12.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock FontSize="11.5" Margin="0,2,0,0" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap">
<Run Text="{Binding ScopesText, Mode=OneWay}" />
<Run Text="· clé" />
<Run Text="{Binding KeyId, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</DockPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsAdded}" Value="True">
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource SuccessBrush}" />
<Setter TargetName="ChangeText" Property="Foreground" Value="{DynamicResource SuccessBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsRemoved}" Value="True">
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="ChangeText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="Row" Property="Opacity" Value="0.75" />
</DataTrigger>
<DataTrigger Binding="{Binding IsChanged}" Value="True">
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="ChangeText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Contenu en ligne et sa signature -->
<DataTemplate DataType="{x:Type asig:SignedContentItem}">
<DockPanel Margin="0,0,0,10">
<Path x:Name="Icon" Width="15" Height="15" Stretch="Uniform" VerticalAlignment="Top" Margin="0,2,0,0"
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}" />
<StackPanel Margin="12,0,0,0">
<TextBlock Text="{Binding Title}" FontSize="12.5" FontWeight="SemiBold" />
<TextBlock x:Name="Text" Text="{Binding Text}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
</DockPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding WillBreak}" Value="True">
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="Icon" Property="Data" Value="{DynamicResource Icon.Alert}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsValid}" Value="False">
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="Icon" Property="Data" Value="{DynamicResource Icon.AlertCircle}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<DataTemplate DataType="{x:Type asig:SigningPanelViewModel}">
<StackPanel>
<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}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Publication du trousseau non configurée sur le serveur : renseignez LauncherApi:Catalog:PublicFile (le trousseau est alors écrit à côté du catalogue) ou LauncherApi:Keyring:PublicFile (server/deploy/LISEZMOI.md, étape 3 quinquies)." />
</Border>
<!-- Trousseau publié -->
<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.ShieldKey}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Trousseau publié" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding KeyringText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0" Visibility="{Binding HasKeyring, Converter={StaticResource BoolToVisibility}}">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
VerticalAlignment="Top" Margin="0,1,0,0" Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}}" />
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding RootText}" FontSize="12" Margin="8,0,0,0" TextWrapping="Wrap" MaxWidth="600" />
</StackPanel>
<TextBlock FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding KeyringUrl, Converter={StaticResource StringToVisibility}}">
<Run Text="Adresse à mettre dans « keyringUrl » de launcher.json :" />
<Run Text="{Binding KeyringUrl, Mode=OneWay}" FontFamily="Cascadia Mono, Consolas, Courier New" />
</TextBlock>
<TextBlock Text="{Binding RequireText}" Style="{StaticResource AdminHintStyle}" Margin="0,6,0,0" />
</StackPanel>
</Grid>
</Border>
<!-- Trousseau à jour -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Clés autorisées à signer" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="D'après les appareils actifs des administrateurs autorisés à publier une version (propriétaires, administrateurs) ou le catalogue (éditeurs compris). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
<TextBlock Text="{Binding ChangesText}" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,10" />
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" />
<DockPanel Margin="0,10,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource SmallButtonStyle}" Content="Exporter la demande…" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding ExportRequestCommand}"
ToolTip="Enregistre le trousseau proposé, à signer hors ligne avec tools\sign-keyring.bat" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Publier le trousseau signé…"
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishSignedCommand}"
ToolTip="Vérifie la signature racine avec la clé intégrée à ce launcher, puis publie" />
</StackPanel>
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="1. Exporter la demande. 2. La signer avec tools\sign-keyring.bat (clé racine, hors ligne). 3. Publier le trousseau signé." />
</DockPanel>
</StackPanel>
</Border>
<!-- Contenu en ligne -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Contenu en ligne" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Versions et catalogue tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
<ItemsControl ItemsSource="{Binding Contents}" Focusable="False" />
</StackPanel>
</Border>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="La clé racine (launcher-signing.key) n'est jamais ouverte par le launcher ni envoyée au serveur." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
</ResourceDictionary>
+7
View File
@@ -86,4 +86,11 @@
<Geometry x:Key="Icon.Restore">M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3Z</Geometry>
<Geometry x:Key="Icon.ContentSaveOutline">M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3M19 19H5V5H16.17L19 7.83V19M12 12C10.34 12 9 13.34 9 15S10.34 18 12 18 15 16.66 15 15 13.66 12 12 12M6 6H15V10H6V6Z</Geometry>
<!-- Publication d'une version (phase 5) -->
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
<Geometry x:Key="Icon.AccountRemove">M15,14C17.67,14 23,15.33 23,18V20H7V18C7,15.33 12.33,14 15,14M15,12A4,4 0 0,1 11,8A4,4 0 0,1 15,4A4,4 0 0,1 19,8A4,4 0 0,1 15,12M5,9.59L7.12,7.46L8.54,8.88L6.41,11L8.54,13.12L7.12,14.54L5,12.41L2.88,14.54L1.46,13.12L3.59,11L1.46,8.88L2.88,7.46L5,9.59Z</Geometry>
</ResourceDictionary>
+37 -13
View File
@@ -6,6 +6,7 @@ using System.Net.Http;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
using GameLauncher.Services;
@@ -519,11 +520,15 @@ public sealed class GameViewModel : ObservableObject, IDisposable
// ------------------------------------------------------------------ Mises à jour
/// <summary>Raison du refus du dernier manifest (signature, phase 7), sinon null.</summary>
private string? _manifestRefusal;
private async Task<GameManifest?> TryFetchManifestAsync()
{
try
{
var manifest = await _manifestClient.FetchAsync(_lifetime.Token);
_manifestRefusal = null;
if (!string.IsNullOrEmpty(manifest.GameId) &&
!string.Equals(manifest.GameId, _config.GameId, StringComparison.OrdinalIgnoreCase))
{
@@ -542,12 +547,32 @@ public sealed class GameViewModel : ObservableObject, IDisposable
{
return null;
}
catch (Exception ex)
catch (ContentSecurityException ex)
{
Log.Error("Impossible de récupérer le manifest", ex);
// Signature absente ou invalide (contentSigning « require ») : rien n'est installé ni mis à jour.
_manifestRefusal = ex.Message;
IsOffline = true;
return null;
}
catch (Exception ex)
{
Log.Error("Impossible de récupérer le manifest", ex);
_manifestRefusal = null;
IsOffline = true;
return null;
}
}
/// <summary>Manifest indisponible : refus de sécurité (signature) expliqué tel quel, sinon serveur injoignable.</summary>
private void ShowManifestUnavailable(string offlineMessage, DialogKind kind = DialogKind.Error)
{
if (_manifestRefusal is { } refusal)
_dialogs.Show("Mise à jour bloquée par sécurité",
"Les informations publiées sur le serveur n'ont pas une signature valide : elles sont refusées pour protéger votre PC.\n\n" +
$"{refusal}\n\nVous pouvez continuer à jouer à la version installée. Si le problème persiste, prévenez l'éditeur du jeu.",
DialogKind.Error);
else
_dialogs.Show("Serveur injoignable", offlineMessage, kind);
}
private async Task CheckForUpdatesAsync(bool userInitiated, bool background = false)
@@ -579,10 +604,15 @@ public sealed class GameViewModel : ObservableObject, IDisposable
{
if (userInitiated)
{
_dialogs.Show("Serveur injoignable",
"Impossible de contacter le serveur de mises à jour. Vérifiez votre connexion Internet puis réessayez.",
ShowManifestUnavailable("Impossible de contacter le serveur de mises à jour. Vérifiez votre connexion Internet puis réessayez.",
DialogKind.Warning);
}
else if (!background && State != LauncherState.Broken && _manifestRefusal is not null)
{
ShowBanner("Mise à jour bloquée par sécurité",
"Les informations publiées sur le serveur n'ont pas une signature valide : elles sont ignorées. Vous pouvez tout de même jouer.",
DialogKind.Error, autoHide: false);
}
else if (!background && State != LauncherState.Broken)
{
ShowBanner("Mode hors ligne",
@@ -699,9 +729,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (manifest is null)
{
RefreshInstallState();
_dialogs.Show("Serveur injoignable",
"Impossible de récupérer les informations du jeu. Vérifiez votre connexion Internet puis réessayez.",
DialogKind.Error);
ShowManifestUnavailable("Impossible de récupérer les informations du jeu. Vérifiez votre connexion Internet puis réessayez.");
return;
}
@@ -735,9 +763,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (manifest is null)
{
RefreshInstallState();
_dialogs.Show("Serveur injoignable",
"Impossible de télécharger la mise à jour. Vérifiez votre connexion Internet puis réessayez.",
DialogKind.Error);
ShowManifestUnavailable("Impossible de télécharger la mise à jour. Vérifiez votre connexion Internet puis réessayez.");
return;
}
@@ -772,9 +798,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (manifest is null)
{
RefreshInstallState();
_dialogs.Show("Serveur injoignable",
"La réparation nécessite une connexion au serveur. Vérifiez votre connexion Internet puis réessayez.",
DialogKind.Error);
ShowManifestUnavailable("La réparation nécessite une connexion au serveur. Vérifiez votre connexion Internet puis réessayez.");
ReportLinksOnly();
return;
}
+9
View File
@@ -37,6 +37,15 @@
// Supprimez la ligne pour n'utiliser que les valeurs de ce fichier.
"catalogUrl": "https://launcher.lanisys.fr/catalog/catalog.json",
// Signature du contenu (phase 7) : trousseau des clés autorisées à signer versions et catalogue, lui-même
// signé par la clé de mise à jour du launcher (Security\update-signing.pub). « warn » : problèmes inscrits
// au journal ; « require » : contenu non signé refusé. Comme apiUrl, lus UNIQUEMENT dans le launcher.json
// intégré à l'exe. À décommenter une fois le trousseau publié (server/deploy/LISEZMOI.md, étape 3 quinquies).
// « keyringMinSequence » : numéro minimal du trousseau accepté, à relever après le retrait d'une clé compromise.
"keyringUrl": "https://launcher.lanisys.fr/catalog/keyring.json",
"contentSigning": "require",
"keyringMinSequence": 1,
"websiteUrl": "https://lanisys.fr",
// Thème du launcher pour ce jeu : "default" (violet/cyan) ou "exovival" (couloir sombre, vert).
@@ -0,0 +1,264 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Pages « Administrateurs » et « Mes appareils » du menu d'administration.</summary>
public class AccountsPanelTests
{
private static readonly ModuleCatalog Modules =
new(new ILauncherModule[] { new AdminModule(), new AccountsAdminModule(), new AuditAdminModule() });
private static async Task<(AdminShellViewModel Shell, AdminWorkspaceViewModel Workspace)> OpenAsync(TestApi api, string folder, FakeDialogs dialogs)
{
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
return (shell, Assert.IsType<AdminWorkspaceViewModel>(shell.Screen));
}
private static async Task<T> PageAsync<T>(AdminWorkspaceViewModel workspace, string panelId) where T : AdminPanelViewModel
{
workspace.Navigate(panelId);
await workspace.Activation;
return Assert.IsType<T>(workspace.SelectedPanel!.ViewModel);
}
[Fact]
public async Task An_owner_invites_approves_by_fingerprint_changes_roles_and_revokes()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
var page = await PageAsync<AdminsPanelViewModel>(workspace, AdminsPanelViewModel.PanelId);
// Son propre compte : ni rôle à changer, ni révocation, ni révocation de la clé de la session en cours.
var me = Assert.Single(page.Admins);
Assert.True(me.IsYou);
Assert.False(me.CanChangeRole);
Assert.False(me.CanRevoke);
Assert.True(Assert.Single(me.Keys).IsCurrentSession);
Assert.False(me.Keys[0].CanRevoke);
// Invitation : code affiché une seule fois, copiable, puis oublié.
Assert.False(page.InviteCommand.CanExecute(null));
page.NewName = " Paul ";
page.NewRole = page.RoleChoices.First(r => r.Value == AdminRoles.Admin);
await page.InviteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var code = page.CreatedInvitation!.Code;
Assert.Contains("Paul", page.CreatedInvitation.Title);
Assert.Equal("", page.NewName);
Assert.Equal("Paul · Administrateur", Assert.Single(page.Invitations).Title);
page.CreatedInvitation.CopyCommand.Execute(null);
Assert.Equal(code, dialogs.Clipboard);
Assert.True(page.CreatedInvitation.Copied);
page.CreatedInvitation.DismissCommand.Execute(null);
Assert.False(page.HasCreatedInvitation);
// Paul enregistre sa clé ; son compte apparaît en premier, à approuver.
using var paulKey = TestApi.NewKey();
var enrolled = await api.CreateClient().EnrollAsync(code, paulKey, "PC de Paul");
await page.RefreshCommand.ExecuteAsync();
var paul = page.Admins[0];
Assert.Equal("Paul", paul.DisplayName);
Assert.True(paul.IsPending);
var paulDevice = Assert.Single(paul.Keys);
Assert.True(paulDevice.CanApprove);
Assert.Null(paulDevice.Fingerprint); // masquée : à se faire dicter
Assert.Empty(page.Invitations); // code utilisé
// Empreinte incomplète : refusée avant tout envoi ; empreinte fausse : refusée par le serveur.
var confirmations = dialogs.Confirmations.Count;
paulDevice.FingerprintInput = "AB12 CD34";
await paulDevice.ApproveCommand.ExecuteAsync();
Assert.Contains("incomplète", page.ErrorMessage);
Assert.Equal(confirmations, dialogs.Confirmations.Count);
paulDevice.FingerprintInput = "0000 0000 0000 0000 0000";
await paulDevice.ApproveCommand.ExecuteAsync();
Assert.Contains("ne correspond pas", page.ErrorMessage);
Assert.True(page.Admins[0].IsPending);
page.Admins[0].Keys[0].FingerprintInput = enrolled.Fingerprint.ToLowerInvariant().Replace(" ", "");
await page.Admins[0].Keys[0].ApproveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("approuvé", page.StatusMessage);
paul = page.Admins.Single(a => a.DisplayName == "Paul");
Assert.False(paul.IsPending);
Assert.NotNull(paul.Keys[0].Fingerprint);
// Nouvel appareil de Paul (compte actif) : c'est à lui de l'approuver, pas au propriétaire.
await using (var paulSession = await api.LoginAsync(paulKey))
{
var device = await paulSession.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paul.Id });
await api.CreateClient().EnrollAsync(device.Code, TestApi.NewKey(), "PC fixe de Paul");
}
await page.RefreshCommand.ExecuteAsync();
var paulPending = page.Admins.Single(a => a.DisplayName == "Paul").Keys[0];
Assert.True(paulPending.IsPending);
Assert.False(paulPending.CanApprove);
Assert.True(paulPending.HasApproveHint);
Assert.Contains("lui-même", paulPending.ApproveHint);
paul = page.Admins.Single(a => a.DisplayName == "Paul");
// Rôle : confirmation refusée → rien ne change ; acceptée → appliqué.
paul.SelectedRole = paul.RoleChoices.First(r => r.Value == AdminRoles.Owner);
Assert.True(paul.RoleChanged);
dialogs.ConfirmAnswer = false;
await paul.ChangeRoleCommand.ExecuteAsync();
Assert.Equal(AdminRoles.Admin, paul.SelectedRole.Value);
dialogs.ConfirmAnswer = true;
paul.SelectedRole = paul.RoleChoices.First(r => r.Value == AdminRoles.Editor);
await paul.ChangeRoleCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
paul = page.Admins.Single(a => a.DisplayName == "Paul");
Assert.Equal("Éditeur", paul.RoleText);
// Révocation du compte : masqué par défaut, visible avec « comptes révoqués », plus aucune action.
await paul.RevokeCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.DoesNotContain(page.Admins, a => a.DisplayName == "Paul");
Assert.Contains("(1)", page.RevokedText);
page.ShowRevoked = true;
paul = page.Admins.Single(a => a.DisplayName == "Paul");
Assert.True(paul.IsRevoked);
Assert.False(paul.CanRevoke);
Assert.False(paul.CanChangeRole);
Assert.False(paul.Keys[0].CanRevoke);
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(paulKey));
// Invitation supprimée.
page.NewName = "Julie";
await page.InviteCommand.ExecuteAsync();
await Assert.Single(page.Invitations).DeleteCommand.ExecuteAsync();
Assert.Empty(page.Invitations);
await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().EnrollAsync(page.CreatedInvitation!.Code, TestApi.NewKey(), null));
}
[Fact]
public async Task An_editor_adds_and_approves_a_new_pc_from_their_devices_page()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path, "paul");
using var _ = signer;
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Paul", Role = AdminRoles.Editor });
var first = await api.CreateClient().EnrollAsync(invitation.Code, signer, "Portable");
await owner.ApproveKeyAsync(first.AdminId, signer.KeyId, first.Fingerprint);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
// Un éditeur : sa page « Mes appareils », ni la gestion des administrateurs ni le journal d'audit.
Assert.Contains(workspace.PanelItems, p => p.Descriptor.Id == MyDevicesPanelViewModel.PanelId);
Assert.DoesNotContain(workspace.PanelItems, p => p.Descriptor.Id == AdminsPanelViewModel.PanelId);
Assert.DoesNotContain(workspace.PanelItems, p => p.Descriptor.Id == AuditPanelViewModel.PanelId);
var page = await PageAsync<MyDevicesPanelViewModel>(workspace, MyDevicesPanelViewModel.PanelId);
Assert.Equal("Paul", page.DisplayName);
Assert.False(page.CanRevokeDevices);
var laptop = Assert.Single(page.Devices);
Assert.True(laptop.IsCurrentSession);
Assert.False(laptop.CanRevoke);
Assert.False(laptop.CanApprove);
// Nouvel appareil : invitation, code saisi sur le nouveau PC, approbation ici.
await page.AddDeviceCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var code = page.CreatedInvitation!.Code;
page.CreatedInvitation.DismissCommand.Execute(null);
Assert.True(Assert.Single(page.Invitations).CanDelete);
using var desktopKey = TestApi.NewKey();
var desktop = await api.CreateClient().EnrollAsync(code, desktopKey, "PC fixe");
await page.RefreshCommand.ExecuteAsync();
var pending = page.Devices[0];
Assert.Equal("PC fixe", pending.Label);
Assert.True(pending.CanApprove);
Assert.Empty(page.Invitations);
pending.FingerprintInput = desktop.Fingerprint;
await pending.ApproveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.All(page.Devices, d => Assert.True(d.IsActive));
await (await api.LoginAsync(desktopKey)).DisposeAsync();
// Ses invitations se suppriment ; celle créée par un propriétaire pour lui, non (le serveur refuserait).
await page.AddDeviceCommand.ExecuteAsync();
await owner.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = first.AdminId });
await page.RefreshCommand.ExecuteAsync();
Assert.Equal(new[] { true, false }, page.Invitations.Select(i => i.CanDelete).ToArray());
await page.Invitations[0].DeleteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(Assert.Single(page.Invitations).CanDelete);
}
[Fact]
public async Task An_owner_adds_then_revokes_one_of_their_own_pcs()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var (shell, workspace) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
var page = await PageAsync<MyDevicesPanelViewModel>(workspace, MyDevicesPanelViewModel.PanelId);
Assert.True(page.CanRevokeDevices);
await page.AddDeviceCommand.ExecuteAsync();
Assert.False(page.AddDeviceCommand.CanExecute(null)); // code affiché, pas encore transmis
var code = page.CreatedInvitation!.Code;
page.CreatedInvitation.DismissCommand.Execute(null);
Assert.True(page.AddDeviceCommand.CanExecute(null));
using var spareKey = TestApi.NewKey();
var spare = await api.CreateClient().EnrollAsync(code, spareKey, "PC de secours");
await page.RefreshCommand.ExecuteAsync();
Assert.Contains("nouveau PC", page.Devices[0].FingerprintText);
page.Devices[0].FingerprintInput = spare.Fingerprint;
await page.Devices[0].ApproveCommand.ExecuteAsync();
Assert.Contains("approuvé", page.StatusMessage);
// Révoquer le PC de secours (pas celui de la session en cours) ; le message précédent disparaît.
var device = page.Devices.Single(d => d.Label == "PC de secours");
Assert.True(device.CanRevoke);
Assert.False(page.Devices.Single(d => d.IsCurrentSession).CanRevoke);
await device.RevokeCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("révoqué", page.StatusMessage);
Assert.True(page.Devices.Single(d => d.Label == "PC de secours").IsRevoked);
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(spareKey));
}
[Fact]
public async Task The_dashboard_leads_to_the_accounts_pages()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var (shell, workspace) = await OpenAsync(api, folder.Path, new FakeDialogs());
await using var __ = shell;
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel!.ViewModel);
Assert.True(dashboard.CanOpenAdmins);
dashboard.OpenAdminsCommand.Execute(null);
await workspace.Activation;
Assert.IsType<AdminsPanelViewModel>(workspace.SelectedPanel!.ViewModel);
workspace.Navigate(DashboardPanelViewModel.PanelId);
dashboard.OpenDevicesCommand.Execute(null);
await workspace.Activation;
Assert.IsType<MyDevicesPanelViewModel>(workspace.SelectedPanel!.ViewModel);
}
}
@@ -132,6 +132,23 @@ public class AdminLoginAndEnrollmentTests
await Assert.ThrowsAsync<InvalidOperationException>(() => service.LoginAsync(folder.File("launcher-signing - Copie.key"), AdminTestKit.Passphrase));
}
[Fact]
public async Task The_launcher_root_key_is_refused_whatever_the_file_name()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
// Clé racine renommée « ma-cle.key » (et même enregistrée comme administrateur) : jamais utilisée pour se connecter.
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
var root = (signer.KeyId, signer.PublicKey);
using (signer) Owner(api, signer);
var refused = await Assert.ThrowsAsync<InvalidOperationException>(() =>
new AdminLoginService(api.CreateClient(), "tests", rootKeys: new[] { root }).LoginAsync(path, AdminTestKit.Passphrase));
Assert.Contains("clé racine", refused.Message);
await using var normal = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase);
Assert.Equal(normal.Fingerprint, normal.Session.LocalFingerprint);
}
[Fact]
public async Task Unknown_key_gets_a_clear_message()
{
@@ -84,8 +84,10 @@ public static class AdminTestKit
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
IAdminTpm? tpm = null, IReadOnlyList<LauncherConfig>? games = null,
IReadOnlyList<string>? themes = null) => new()
IReadOnlyList<string>? themes = null,
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null) => new()
{
RootKeys = rootKeys ?? Array.Empty<(string, byte[])>(),
Tpm = tpm,
Client = api.CreateClient(),
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
@@ -0,0 +1,112 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Journal d'audit » du menu d'administration.</summary>
public class AuditPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new AuditAdminModule() });
[Fact]
public async Task The_journal_is_filtered_paged_and_exported()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
// Une connexion refusée (clé inconnue) et 260 invitations créées puis supprimées (520 entrées).
using var stranger = TestApi.NewKey();
await Assert.ThrowsAsync<ApiException>(() => api.LoginAsync(stranger));
await using (var owner = await api.LoginAsync(signer))
{
for (var i = 0; i < 260; i++)
{
var invitation = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = $"Invité {i}", Role = AdminRoles.Editor });
await owner.DeleteInvitationAsync(invitation.Invitation.Id);
}
}
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(AuditPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<AuditPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Première page : 200 entrées, la plus récente d'abord (la connexion de cette fenêtre).
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(AuditPanelViewModel.PageSize, page.Entries.Count);
Assert.Equal("Connexion", page.Entries[0].ActionText);
Assert.True(page.Entries[0].IsOk);
Assert.True(page.CanLoadMore);
// Tout charger : au plus 500 entrées affichées, le reste signalé.
while (page.CanLoadMore) await page.LoadMoreCommand.ExecuteAsync();
Assert.Contains("début du journal", page.SummaryText);
Assert.Equal(AuditPanelViewModel.MaxDisplayed, page.Entries.Count);
Assert.True(page.IsTruncated);
// Filtres : catégorie, résultat, texte.
page.Category = page.Categories.First(c => c.Value == "auth.");
Assert.All(page.Entries, e => Assert.StartsWith("auth.", e.ActionCode));
page.Result = page.Results.First(r => r.Value == AuditResults.Denied);
var refused = Assert.Single(page.Entries);
Assert.Equal("Refusé", refused.ResultText);
Assert.Contains(stranger.KeyId, refused.OriginText);
page.ClearFiltersCommand.Execute(null);
Assert.False(page.HasFilters);
page.SearchText = "invité 42";
Assert.Equal(2, page.Entries.Count); // créée puis supprimée
Assert.All(page.Entries, e => Assert.Contains("Invité 42", e.DetailText));
page.SearchText = "aucune entrée ne contient ceci";
Assert.False(page.HasEntries);
Assert.Contains("Aucune entrée", page.EmptyText);
// Export : toutes les entrées filtrées (pas seulement les 500 affichées).
page.SearchText = "";
dialogs.NextSaveFile = folder.File("journal.csv");
await page.ExportCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var lines = File.ReadAllLines(dialogs.NextSaveFile);
Assert.StartsWith("Date;Action;Code;Résultat", lines[0]);
Assert.True(lines.Length - 1 > AuditPanelViewModel.MaxDisplayed, $"{lines.Length - 1} lignes exportées");
Assert.Contains(dialogs.Messages, m => m.StartsWith("Journal exporté"));
}
[Fact]
public void Exported_cells_are_never_spreadsheet_formulas()
{
var entry = new AuditEntry
{
Timestamp = DateTimeOffset.UnixEpoch,
Action = "auth.login",
Result = AuditResults.Denied,
KeyId = "=HYPERLINK(\"http://x\")",
Ip = "@SUM(A1)",
Target = " -2+3",
ActorName = "Paul,=1+1",
Details = "nom ; avec \"guillemets\"\nsur deux lignes"
};
var line = AuditTexts.ToCsv(new[] { new AuditItem(entry) }).Split(Environment.NewLine)[1];
Assert.Contains(";\"'@SUM(A1)\";", line);
Assert.Contains(";\"'=HYPERLINK(\"\"http://x\"\")\";", line);
Assert.Contains(";\"' -2+3\";", line);
// Toutes les cellules entre guillemets : un tableur qui sépare par des virgules ne coupe pas « Paul,=1+1 ».
Assert.Contains(";\"Paul,=1+1\";", line);
Assert.EndsWith(";\"nom ; avec \"\"guillemets\"\" sur deux lignes\"", line);
Assert.Equal("Tentatives refusées regroupées", AuditTexts.Action("audit.suppressed"));
Assert.Equal("action.inconnue", AuditTexts.Action("action.inconnue"));
Assert.True(AuditTexts.InCategory("request.signature", "security"));
Assert.False(AuditTexts.InCategory("auth.login", "security"));
}
}
@@ -0,0 +1,238 @@
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using System.Windows.Input;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Publier une version » du menu d'administration.</summary>
public class PublishPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new PublishAdminModule() });
[Fact]
public async Task The_page_checks_the_folder_warns_and_publishes()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 4, bigFileBytes: 3 * 1024 * 1024, archiveBytes: 5000);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(PublishPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
Assert.False(page.PublishCommand.CanExecute(null));
// Dossier choisi : contrôlé ; même numéro que la version en ligne → avertissement.
dialogs.NextOpenFile = release.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.HasRelease);
Assert.Equal("Version 1.1", page.VersionText);
Assert.Contains(page.Warnings, w => w.Contains("déjà en ligne"));
Assert.True(page.PublishCommand.CanExecute(null));
// Refus de la confirmation : rien n'est envoyé.
dialogs.ConfirmAnswer = false;
await page.PublishCommand.ExecuteAsync();
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
dialogs.ConfirmAnswer = true;
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.1 publiée", page.StatusMessage);
Assert.False(page.IsPublishing);
Assert.False(page.HasInterrupted);
Assert.Equal("Nouveautés.", JsonNode.Parse(File.ReadAllText(manifest))!["changelog"]![0]!["changes"]![0]!.GetValue<string>());
// Dossier incomplet : message clair.
File.Delete(Path.Combine(release.Folder, "EZR-1.1.zip"));
await page.RecheckCommand.ExecuteAsync();
Assert.True(page.HasError);
Assert.Contains("Archive absente", page.ErrorMessage);
Assert.False(page.HasRelease);
}
[Fact]
public async Task Publishing_from_the_interface_only_notifies_it_from_its_own_thread()
{
// Comme sous WPF : la page vit sur le fil de l'interface, et tout ce qui notifie les boutons
// ou les liaisons doit s'y exécuter (sinon « un autre thread en est propriétaire »).
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 4, bigFileBytes: 3 * 1024 * 1024, archiveBytes: 5000);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs { ConfirmAnswer = true, NextOpenFile = release.ManifestPath };
using var ui = new UiThread();
var offThread = new ConcurrentQueue<string>();
await ui.RunAsync(async () =>
{
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(PublishPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
page.PropertyChanged += (_, e) => { if (!ui.IsCurrent) offThread.Enqueue($"PropertyChanged({e.PropertyName})"); };
foreach (var (name, command) in new (string, ICommand)[]
{
("Choose", page.ChooseCommand), ("Recheck", page.RecheckCommand), ("Publish", page.PublishCommand),
("Cancel", page.CancelCommand), ("Abandon", page.AbandonCommand)
})
command.CanExecuteChanged += (_, _) => { if (!ui.IsCurrent) offThread.Enqueue($"{name}.CanExecuteChanged"); };
await page.ChooseCommand.ExecuteAsync();
Assert.True(page.HasRelease, page.ErrorMessage);
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.2 publiée", page.StatusMessage);
Assert.False(page.HasInterrupted);
});
Assert.Empty(offThread);
Assert.Empty(ui.Errors);
}
[Fact]
public async Task The_interrupted_banner_only_appears_once_the_upload_has_stopped()
{
// Limite des morceaux à 2 par minute : manifest, lot de fichiers, puis l'archive est refusée (429 +
// Retry-After) et l'envoi attend ; la page affiche la pause, puis l'administrateur interrompt l'envoi
// pendant l'attente. (Petits envois : sans nginx devant, Kestrel coupe la connexion au lieu de répondre
// 429 quand il refuse un gros corps qu'il n'a pas lu.)
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 4, bigFileBytes: 100_000, archiveBytes: 5000);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = manifest,
["LauncherApi:Limits:PublicationUploadsPerMinute"] = "2"
});
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs { ConfirmAnswer = true, NextOpenFile = release.ManifestPath };
using var ui = new UiThread();
await ui.RunAsync(async () =>
{
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(PublishPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
await page.ChooseCommand.ExecuteAsync();
Assert.True(page.HasRelease, page.ErrorMessage);
var bannerDuringUpload = false;
string? pause = null;
page.PropertyChanged += (_, _) =>
{
if (page.IsPublishing && page.HasInterrupted) bannerDuringUpload = true;
if (pause is null && page.IsPublishing && page.ProgressText?.Contains("reprise dans") == true)
{
pause = page.ProgressText;
page.CancelCommand.Execute(null);
}
};
await page.PublishCommand.ExecuteAsync();
Assert.False(bannerDuringUpload);
Assert.NotNull(pause);
Assert.Contains("limite de requêtes du serveur atteinte", pause);
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(page.IsPublishing);
Assert.True(page.HasInterrupted); // arrêté : « Publier » reprendra, « Abandonner » nettoie
Assert.Contains("version 1.2", page.InterruptedText);
Assert.Equal("Envoi interrompu.", page.ProgressText);
await page.AbandonCommand.ExecuteAsync();
Assert.False(page.HasInterrupted);
});
Assert.Empty(ui.Errors);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
}
[Fact]
public async Task A_command_raised_from_a_background_thread_notifies_on_the_interface_thread()
{
using var ui = new UiThread();
RelayCommand? command = null;
AsyncRelayCommand? asyncCommand = null;
await ui.RunAsync(() =>
{
command = new RelayCommand(() => { });
asyncCommand = new AsyncRelayCommand(() => Task.CompletedTask);
return Task.CompletedTask;
});
var threads = new ConcurrentQueue<int>();
var both = new CountdownEvent(2);
command!.CanExecuteChanged += (_, _) => { threads.Enqueue(Environment.CurrentManagedThreadId); both.Signal(); };
asyncCommand!.CanExecuteChanged += (_, _) => { threads.Enqueue(Environment.CurrentManagedThreadId); both.Signal(); };
await Task.Run(() =>
{
command.RaiseCanExecuteChanged();
asyncCommand.RaiseCanExecuteChanged();
});
Assert.True(both.Wait(TimeSpan.FromSeconds(10)));
Assert.All(threads, id => Assert.Equal(ui.ThreadId, id));
// Hors de toute interface (tests, services) : notification directe, comme avant.
var direct = new RelayCommand(() => { });
var raised = false;
direct.CanExecuteChanged += (_, _) => raised = true;
direct.RaiseCanExecuteChanged();
Assert.True(raised);
}
[Fact]
public async Task A_long_operation_postpones_the_inactivity_lock()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = 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);
using (workspace.BeginLongOperation())
{
for (var i = 0; i < 6; i++)
{
api.Time.Advance(TimeSpan.FromMinutes(4));
await workspace.Guard.TickAsync();
}
Assert.Same(workspace, shell.Screen); // 24 minutes d'envoi sans souris ni clavier : pas de verrouillage
}
api.Time.Advance(TimeSpan.FromMinutes(11));
await workspace.Guard.TickAsync();
Assert.IsType<LoginViewModel>(shell.Screen); // 10 minutes après la fin de l'opération : verrouillé
}
}
@@ -0,0 +1,142 @@
using System.Text.Json.Nodes;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Server;
using GameLauncher.Tests.Signing;
namespace GameLauncher.Tests.Admin;
/// <summary>Pages « Signature » et « Publier une version » (signature de la version en ligne).</summary>
public class SigningPanelTests
{
private static readonly ModuleCatalog Modules =
new(new ILauncherModule[] { new AdminModule(), new SigningAdminModule(), new PublishAdminModule() });
[Fact]
public async Task The_owner_exports_the_keyring_request_then_publishes_it_once_signed_by_the_root_key()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-signing-tests", Guid.NewGuid().ToString("N"), "catalog");
Directory.CreateDirectory(directory);
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
["LauncherApi:Games:EZR:ManifestFile"] = manifest
});
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
using var root = SigningKit.NewKey();
using var forgedRoot = SigningKit.NewKey();
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules,
rootKeys: new[] { SigningKit.Root(root) }));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(SigningPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<SigningPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Aucun trousseau : la clé du propriétaire est à ajouter ; la version en ligne n'est pas signée.
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(page.HasKeyring);
var key = Assert.Single(page.Keys);
Assert.True(key.IsAdded);
Assert.Equal(signer.KeyId, key.KeyId);
Assert.Contains("versions du jeu", key.ScopesText);
Assert.Contains(page.Contents, c => c.Title.Contains("EZR") && c.Text.StartsWith("Non signé"));
// Export de la demande (contenu non signé : confirmation demandée).
var request = folder.File("keyring-request.json");
dialogs.NextSaveFile = request;
await page.ExportRequestCommand.ExecuteAsync();
Assert.Contains(dialogs.Confirmations, c => c.StartsWith("Contenu à signer d'abord"));
var exported = Keyring.Parse(File.ReadAllBytes(request))!;
Assert.Equal(1, exported.Sequence);
Assert.Null(exported.Signature);
Assert.Equal(signer.KeyId, Assert.Single(exported.Keys).KeyId);
// Trousseau signé par une autre racine : refusé avant tout envoi.
var forged = folder.File("forged.json");
File.WriteAllText(forged, Keyring.Serialize(Keyring.Sign(exported, forgedRoot)));
dialogs.NextOpenFile = forged;
await page.PublishSignedCommand.ExecuteAsync();
Assert.Contains("inconnue", page.ErrorMessage);
Assert.False(page.HasKeyring);
// Signé hors ligne par la vraie racine (tools\sign-keyring.bat) : publié.
var signed = folder.File("keyring.json");
File.WriteAllText(signed, Keyring.Serialize(Keyring.Sign(exported, root)));
dialogs.NextOpenFile = signed;
await page.PublishSignedCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.HasKeyring);
Assert.True(page.RootValid);
Assert.False(page.HasChanges);
Assert.False(page.NeedsNewKeyring);
Assert.False(page.ExportRequestCommand.CanExecute(null));
Assert.Contains("n° 1", page.StatusMessage);
Assert.True(File.Exists(Path.Combine(directory, "keyring.json")));
// Le même trousseau une seconde fois : refusé (numéro déjà publié).
await page.PublishSignedCommand.ExecuteAsync();
Assert.Contains("déjà publié", page.ErrorMessage);
}
[Fact]
public void Device_names_proposed_for_the_public_keyring_are_cleaned()
{
// Caractères invisibles ou d'inversion du sens d'écriture retirés : le nom reste acceptable par les launchers.
var name = SigningPanelViewModel.Truncate("Nicolas — \u202EPC\u200B fixe\n");
Assert.Equal("Nicolas — PC fixe", name);
Assert.True(Keyring.IsDisplaySafe(name));
Assert.Equal(Keyring.MaxNameLength, SigningPanelViewModel.Truncate(new string('a', 500)).Length);
Assert.Equal("Appareil", SigningPanelViewModel.Truncate("\u200B"));
}
[Fact]
public async Task A_version_uploaded_with_winscp_is_signed_from_its_publication_folder()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 2, bigFileBytes: 1000, archiveBytes: 500);
File.Copy(release.ManifestPath, manifest, overwrite: true);
var other = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 3, bigFileBytes: 1000, archiveBytes: 500);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(PublishPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
Assert.Contains("non signée", page.OnlineSignatureText);
// Même numéro, contenu différent : signature impossible avec ce dossier.
dialogs.NextOpenFile = other.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.False(page.CanSignOnline);
Assert.NotNull(page.OnlineMismatchText);
// Le dossier de la version en ligne : signée sans renvoi.
dialogs.NextOpenFile = release.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.True(page.CanSignOnline);
await page.SignOnlineCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.IsOnlineSigned);
Assert.Contains("signée par", page.OnlineSignatureText);
Assert.Equal(signer.KeyId, JsonNode.Parse(File.ReadAllBytes(manifest))!["signature"]!["keyId"]!.GetValue<string>());
}
}
@@ -69,6 +69,7 @@ public sealed class TestApi : IAsyncDisposable
["LauncherApi:DataDirectory"] = dataDirectory,
["LauncherApi:RequireHttps"] = "false",
["LauncherApi:Limits:RequestsPerMinute"] = "100000",
["LauncherApi:Limits:PublicationUploadsPerMinute"] = "100000",
["LauncherApi:Limits:AuthPerMinute"] = "100000",
["LauncherApi:Limits:EnrollmentsPerHour"] = "100000"
};
@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
namespace GameLauncher.Tests.Infrastructure;
/// <summary>
/// Fil « interface » de test : un seul thread avec son SynchronizationContext, comme le Dispatcher WPF.
/// Permet de vérifier qu'un ViewModel ne notifie l'interface que depuis ce fil.
/// </summary>
public sealed class UiThread : IDisposable
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
private readonly ConcurrentQueue<Exception> _errors = new();
public UiThread()
{
using var ready = new ManualResetEventSlim();
var thread = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(new Context(this));
ThreadId = Environment.CurrentManagedThreadId;
ready.Set();
foreach (var (callback, state) in _queue.GetConsumingEnumerable())
{
try
{
callback(state);
}
catch (Exception ex)
{
_errors.Enqueue(ex); // comme une exception non gérée du Dispatcher
}
}
}) { IsBackground = true, Name = "UI (tests)" };
thread.Start();
ready.Wait();
}
public int ThreadId { get; private set; }
/// <summary>Vrai si l'appelant est sur le fil « interface ».</summary>
public bool IsCurrent => Environment.CurrentManagedThreadId == ThreadId;
/// <summary>Exceptions levées par les rappels exécutés sur le fil « interface ».</summary>
public IReadOnlyCollection<Exception> Errors => _errors;
/// <summary>Exécute <paramref name="action"/> sur le fil « interface » (ses <c>await</c> y reviennent).</summary>
public Task RunAsync(Func<Task> action)
{
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Post(async _ =>
{
try
{
await action();
done.SetResult();
}
catch (Exception ex)
{
done.SetException(ex);
}
}, null);
return done.Task;
}
private void Post(SendOrPostCallback callback, object? state)
{
try
{
_queue.Add((callback, state));
}
catch (InvalidOperationException)
{
// fil arrêté (fin du test) : rappel ignoré
}
}
public void Dispose() => _queue.CompleteAdding();
private sealed class Context : SynchronizationContext
{
private readonly UiThread _ui;
public Context(UiThread ui) => _ui = ui;
public override void Post(SendOrPostCallback d, object? state) => _ui.Post(d, state);
public override void Send(SendOrPostCallback d, object? state)
{
if (_ui.IsCurrent)
{
d(state);
return;
}
using var done = new ManualResetEventSlim();
_ui.Post(s =>
{
try
{
d(s);
}
finally
{
done.Set();
}
}, state);
done.Wait();
}
public override SynchronizationContext CreateCopy() => this;
}
}
@@ -0,0 +1,497 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Publishing;
/// <summary>Publication d'une version depuis le menu d'administration (envoi, reprise, mise en ligne).</summary>
public class PublishingTests
{
/// <summary>Dossier produit par ManifestBuilder (--output) : manifest, archive, objects/.</summary>
public sealed class ReleaseFolder
{
public string Folder { get; } = Path.Combine(Path.GetTempPath(), "launcher-publish-tests", Guid.NewGuid().ToString("N"));
public string ManifestPath => Path.Combine(Folder, "manifest.json");
public GameManifest Manifest { get; private set; } = new();
public List<(string Path, byte[] Data)> Files { get; } = new();
public byte[] Archive { get; private set; } = Array.Empty<byte>();
public static ReleaseFolder Create(string version = "1.2", int smallFiles = 30, int bigFileBytes = 5 * 1024 * 1024,
int archiveBytes = 9 * 1024 * 1024 + 123, byte[]? shared = null)
{
var release = new ReleaseFolder();
Directory.CreateDirectory(release.Folder);
release.Files.Add(("Exovival Zombie Reborn.exe", RandomNumberGenerator.GetBytes(40_000)));
for (var i = 0; i < smallFiles; i++) release.Files.Add(($"Data/level{i}.pak", RandomNumberGenerator.GetBytes(1000 + i * 137)));
release.Files.Add(("Data/empty.txt", Array.Empty<byte>()));
release.Files.Add(("Data/big.assets", RandomNumberGenerator.GetBytes(bigFileBytes)));
if (shared is not null) release.Files.Add(("Data/shared.pak", shared));
release.Archive = RandomNumberGenerator.GetBytes(archiveBytes);
release.Write(version);
return release;
}
public void Write(string version, Action<GameManifest>? edit = null)
{
foreach (var (_, data) in Files)
{
var path = Path.Combine(Folder, ManifestCheck.ObjectPath(Sha(data)));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, data);
}
var archiveName = $"EZR-{version}.zip";
File.WriteAllBytes(Path.Combine(Folder, archiveName), Archive);
Manifest = new GameManifest
{
GameId = "EZR",
Version = version,
ReleaseDate = DateTimeOffset.Now,
Executable = "Exovival Zombie Reborn.exe",
Archive = new ArchiveInfo { Url = archiveName, Size = Archive.Length, Sha256 = Sha(Archive) },
Files = Files.Select(f => new ManifestFile { Path = f.Path, Size = f.Data.Length, Sha256 = Sha(f.Data) }).ToList(),
Changelog = new List<ChangelogEntry>
{
new() { Version = version, Date = DateTimeOffset.Now, Title = $"Version {version}", Changes = new() { "Nouveautés." } },
new() { Version = "1.1", Title = "Notes locales de la 1.1 (pas celles du serveur)", Changes = new() { "ancien" } }
}
};
edit?.Invoke(Manifest);
File.WriteAllText(ManifestPath, JsonSerializer.Serialize(Manifest, JsonDefaults.Options));
}
}
public static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
private static Dictionary<string, string?> Settings(string manifest) => new() { ["LauncherApi:Games:EZR:ManifestFile"] = manifest };
/// <summary>Avancement transmis immédiatement (Progress&lt;T&gt; passerait par le pool de threads).</summary>
private sealed class Sync<T> : IProgress<T>
{
private readonly Action<T> _action;
public Sync(Action<T> action) => _action = action;
public void Report(T value) => _action(value);
}
[Fact]
public async Task A_version_is_uploaded_and_put_online_with_the_server_notes_history()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var gameDirectory = Path.GetDirectoryName(manifest)!;
// Un fichier est déjà sur le serveur (publication précédente) : il n'est pas renvoyé.
var shared = RandomNumberGenerator.GetBytes(3000);
var sharedPath = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(shared)));
Directory.CreateDirectory(Path.GetDirectoryName(sharedPath)!);
File.WriteAllBytes(sharedPath, shared);
var release = ReleaseFolder.Create(shared: shared);
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
Assert.Equal("1.2", local.Summary.Version);
Assert.Equal("Version 1.2", local.Summary.NotesTitle);
var reports = new List<UploadProgress>();
PublicationState? started = null;
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, new Sync<UploadProgress>(reports.Add), new Sync<PublicationState>(s => started = s), default);
Assert.Equal("1.2", result.Version);
Assert.Equal("1.1", result.PreviousVersion);
Assert.Equal(release.Files.Count - 1, result.ObjectsAdded); // le fichier partagé était déjà là
Assert.True(result.ArchiveAdded);
Assert.Equal("1.1", started!.PublishedVersion);
Assert.Equal(reports[^1].Total, reports.Where(r => r.Step.StartsWith("Envoi")).Max(r => r.Done));
Assert.Contains(reports, r => r.Step == "Mise en ligne");
// Fichiers en ligne, identiques à ceux du dossier ; aucun dépôt laissé.
foreach (var (_, data) in release.Files)
Assert.Equal(data, File.ReadAllBytes(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(data)))));
Assert.Equal(release.Archive, File.ReadAllBytes(Path.Combine(gameDirectory, "EZR-1.2.zip")));
Assert.Empty(Directory.GetDirectories(gameDirectory, ".publish-*"));
Assert.Empty(Directory.GetFiles(Path.Combine(api.DataDirectory, "publications")).Where(f => !f.EndsWith(".done.json")));
if (!OperatingSystem.IsWindows())
{
var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead; // comme le manifest en ligne
Assert.Equal(expected, File.GetUnixFileMode(Path.Combine(gameDirectory, "EZR-1.2.zip")));
Assert.True(File.GetUnixFileMode(Path.GetDirectoryName(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(release.Files[0].Data))))!)
.HasFlag(UnixFileMode.GroupExecute)); // dossiers objects/xx traversables par nginx
}
// Manifest : celui de la version, avec l'historique des notes du SERVEUR (pas les notes locales de la 1.1).
var online = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
online.Validate();
Assert.Equal("1.2", online.Version);
Assert.Equal(new[] { "1.2", "1.1", "1.0" }, online.Changelog.Select(e => e.Version));
Assert.Equal("Version 1.1", online.Changelog[1].Title);
Assert.Equal(release.Manifest.Files.Count, online.Files.Count);
var audit = await owner.GetAuditAsync();
Assert.Contains(audit.Entries, e => e.Action == "release.start" && e.Target == "EZR 1.2");
Assert.Contains(audit.Entries, e => e.Action == "release.publish" && e.Target == "EZR 1.2" && e.Details!.Contains("1.1"));
}
[Fact]
public async Task Notes_edited_online_during_the_upload_are_kept_and_a_republication_sends_nothing_again()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create(smallFiles: 3, bigFileBytes: 10, archiveBytes: 2000);
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
var started = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
});
// Correction des notes de la 1.1 (page Notes de version) pendant l'envoi : permise, et conservée.
var notes = await owner.GetChangelogAsync("EZR");
notes.Entries[0].Title = "1.1 corrigée en ligne";
await owner.SaveChangelogEntryAsync("EZR", notes.Entries[0], notes.ManifestSha256);
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default);
var online = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
Assert.Equal("1.1 corrigée en ligne", online.Changelog[1].Title);
// Réponse de mise en ligne perdue : le nouvel essai renvoie le même résultat, sans republier.
var again = await owner.CommitPublicationAsync(started.PublicationId);
Assert.Equal(result.ManifestSha256, again.ManifestSha256);
Assert.Single((await owner.GetAuditAsync()).Entries, e => e.Action == "release.publish");
// Même version republiée : fichiers et archive déjà en ligne, rien n'est renvoyé.
var republished = await ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default);
Assert.Equal(0, republished.ObjectsAdded);
Assert.False(republished.ArchiveAdded);
Assert.Equal("1.2", republished.PreviousVersion);
}
[Fact]
public async Task An_abandoned_publication_can_be_taken_over_after_30_minutes()
{
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var first = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = new string('a', 64), ManifestSize = 100
});
var (otherKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
using var __ = otherKey;
api.Time.Advance(TimeSpan.FromMinutes(31));
await using var other = await api.LoginAsync(otherKey);
var second = await other.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.3", ManifestSha256 = new string('b', 64), ManifestSize = 100
});
Assert.NotEqual(first.PublicationId, second.PublicationId);
Assert.Single(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
await using var ownerAgain = await api.LoginAsync(ownerKey); // session précédente expirée (31 min)
var audit = await ownerAgain.GetAuditAsync();
Assert.Contains(audit.Entries, e => e.Action == "release.cancel" && e.Target == "EZR 1.2");
}
[Fact]
public async Task An_interrupted_upload_resumes_where_it_stopped()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
// Interruption pendant l'envoi de l'archive (après son premier morceau).
using var cancel = new CancellationTokenSource();
var progress = new Sync<UploadProgress>(p =>
{
if (p.Step.StartsWith("Envoi de l'archive") && p.Done >= p.Total - release.Archive.Length + PublishingLimits.ChunkBytes) cancel.Cancel();
});
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ReleaseUploader.PublishAsync(owner, "EZR", local, progress, null, cancel.Token));
var directory = Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*").Single();
var id = Path.GetFileName(directory)[".publish-".Length..];
var state = await owner.GetPublicationAsync(id);
Assert.True(state.ManifestAccepted);
Assert.Empty(state.PendingObjects);
Assert.False(state.ArchiveComplete);
Assert.Equal(PublishingLimits.ChunkBytes, state.ArchiveReceived);
Assert.Equal("1.1", JsonNode.Parse(File.ReadAllText(manifest))!["version"]!.GetValue<string>()); // rien en ligne
// Reprise : seul le reste de l'archive est envoyé.
var reports = new List<UploadProgress>();
PublicationState? resumed = null;
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, new Sync<UploadProgress>(reports.Add), new Sync<PublicationState>(s => resumed = s), default);
Assert.True(resumed!.Resumed);
Assert.Equal(id, resumed.PublicationId);
Assert.Equal(release.Archive.Length - PublishingLimits.ChunkBytes, reports.Max(r => r.Total));
Assert.Equal("1.2", result.Version);
Assert.Equal(release.Archive, File.ReadAllBytes(Path.Combine(Path.GetDirectoryName(manifest)!, "EZR-1.2.zip")));
}
[Fact]
public async Task A_version_published_during_the_upload_is_never_overwritten()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create(smallFiles: 3, bigFileBytes: 10, archiveBytes: 1000);
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
});
// Une version 1.5 est envoyée avec WinSCP pendant ce temps.
var winscp = File.ReadAllText(manifest).Replace("\"version\": \"1.1\"", "\"version\": \"1.5\"");
File.WriteAllText(manifest, winscp);
var refused = await Assert.ThrowsAsync<ApiException>(() => ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default));
Assert.Equal(409, refused.Status);
Assert.Contains("1.5", refused.Message);
Assert.Equal(winscp, File.ReadAllText(manifest));
Assert.False(File.Exists(Path.Combine(Path.GetDirectoryName(manifest)!, "EZR-1.2.zip")));
}
[Fact]
public async Task Unsafe_or_inconsistent_publications_are_refused()
{
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
// Manifest dangereux, contrôlé par le launcher ET par le serveur.
var release = ReleaseFolder.Create(smallFiles: 2, bigFileBytes: 10, archiveBytes: 100);
release.Write("1.2", m =>
{
m.Archive.Url = "../../etc/EZR.zip";
m.Files.Add(new ManifestFile { Path = "../outside.dll", Size = 1, Sha256 = new string('c', 64) });
});
var local = Assert.Throws<ReleaseCheckException>(() => ReleaseUploader.Inspect(release.ManifestPath, "EZR"));
Assert.Contains("../outside.dll", local.Message);
Assert.Contains("simple nom de fichier", local.Message);
var bytes = File.ReadAllBytes(release.ManifestPath);
var state = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = Sha(bytes), ManifestSize = bytes.Length
});
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.UploadManifestAsync(state.PublicationId, 0, bytes));
Assert.Equal(400, refused.Status);
Assert.Contains("Manifest refusé", refused.Message);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*")); // publication abandonnée
// Fichier modifié après la génération : refusé avant la mise en ligne.
var changed = ReleaseFolder.Create(smallFiles: 2, bigFileBytes: 10, archiveBytes: 100);
var victim = Path.Combine(changed.Folder, ManifestCheck.ObjectPath(Sha(changed.Files[1].Data)));
var tampered = File.ReadAllBytes(victim);
tampered[0] ^= 0xFF;
File.WriteAllBytes(victim, tampered);
var inspected = ReleaseUploader.Inspect(changed.ManifestPath, "EZR"); // même taille : vu seulement à l'envoi
var error = await Assert.ThrowsAsync<ReleaseCheckException>(() => ReleaseUploader.PublishAsync(owner, "EZR", inspected, null, null, default));
Assert.Contains("a changé depuis la génération", error.Message);
// Le serveur vérifie aussi chaque objet reçu.
var publication = (await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = inspected.ManifestSha256, ManifestSize = inspected.ManifestBytes.Length
}));
await owner.UploadManifestAsync(publication.PublicationId, 0, inspected.ManifestBytes);
var sha = Sha(changed.Files[1].Data);
var bad = await Assert.ThrowsAsync<ApiException>(() =>
owner.UploadObjectBatchAsync(publication.PublicationId, ObjectBatch.Write(new[] { (sha, tampered) })));
Assert.Equal(400, bad.Status);
var notExpected = await Assert.ThrowsAsync<ApiException>(() =>
owner.UploadObjectBatchAsync(publication.PublicationId, ObjectBatch.Write(new[] { (Sha(new byte[] { 1 }), new byte[] { 1 }) })));
Assert.Contains("pas attendu", notExpected.Message);
var offset = await Assert.ThrowsAsync<ApiException>(() => owner.UploadArchiveAsync(publication.PublicationId, 50, new byte[10]));
Assert.Equal(ApiErrorCodes.OffsetMismatch, offset.Code);
var incomplete = await Assert.ThrowsAsync<ApiException>(() => owner.CommitPublicationAsync(publication.PublicationId));
Assert.Contains("Envoi incomplet", incomplete.Message);
// Un autre administrateur ne peut ni la poursuivre, ni la remplacer tant qu'elle est active.
var (otherKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
using var __ = otherKey;
await using var other = await api.LoginAsync(otherKey);
var forbidden = await Assert.ThrowsAsync<ApiException>(() => other.GetPublicationAsync(publication.PublicationId));
Assert.Equal(403, forbidden.Status);
var busy = await Assert.ThrowsAsync<ApiException>(() => other.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.3", ManifestSha256 = new string('d', 64), ManifestSize = 10
}));
Assert.Equal(ApiErrorCodes.PublicationInProgress, busy.Code);
// Abandon : dépôt supprimé.
await owner.CancelPublicationAsync(publication.PublicationId);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
var gone = await Assert.ThrowsAsync<ApiException>(() => owner.GetPublicationAsync(publication.PublicationId));
Assert.Equal(404, gone.Status);
}
[Fact]
public async Task Only_upload_routes_accept_large_bodies_and_editors_cannot_publish()
{
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var release = ReleaseFolder.Create(smallFiles: 1, bigFileBytes: 10, archiveBytes: 9 * 1024 * 1024);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
var state = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
});
await owner.UploadManifestAsync(state.PublicationId, 0, local.ManifestBytes);
// 8 Mo : accepté sur une route d'envoi ; au-delà : refusé.
var ok = await owner.UploadArchiveAsync(state.PublicationId, 0, release.Archive.AsSpan(0, 8 * 1024 * 1024).ToArray());
Assert.Equal(8 * 1024 * 1024, ok.Received);
// Requêtes trop grosses : chacune sur sa propre connexion (Kestrel ferme la connexion après un refus).
await using (var oversize = await api.LoginAsync(ownerKey))
{
var tooLarge = await Assert.ThrowsAsync<ApiException>(() =>
oversize.UploadArchiveAsync(state.PublicationId, ok.Received, new byte[(int)PublishingLimits.MaxUploadRequestBytes + 1]));
Assert.True(tooLarge.Status is 413 or 0, tooLarge.Message); // 413, ou connexion coupée par Kestrel pendant l'envoi
}
// Les autres routes gardent la limite de 64 Ko.
await using (var oversize = await api.LoginAsync(ownerKey))
{
var bigJson = await Assert.ThrowsAsync<ApiException>(() => oversize.SaveChangelogEntryAsync("EZR",
new ChangelogEntry { Version = "1.1", Title = new string('x', 70_000) }, new string('a', 64)));
Assert.True(bigJson.Status is 413 or 0, bigJson.Message);
}
// Un éditeur (contenu) ne peut pas publier.
var (editorKey, _) = await api.AddAdminAsync(owner, "Léa", AdminRoles.Editor);
using var __ = editorKey;
await using var editor = await api.LoginAsync(editorKey);
var denied = await Assert.ThrowsAsync<ApiException>(() => editor.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
}));
Assert.Equal(403, denied.Status);
}
[Fact]
public void Manifest_rules_are_shared_by_the_launcher_and_the_server()
{
var release = ReleaseFolder.Create(smallFiles: 1, bigFileBytes: 10, archiveBytes: 10);
var bytes = File.ReadAllBytes(release.ManifestPath);
var errors = new List<string>();
Assert.NotNull(ManifestCheck.Check(bytes, "ezr", "1.2", errors));
Assert.Null(ManifestCheck.Check(bytes, "AUTRE", "1.2", errors));
Assert.Contains(errors, e => e.Contains("AUTRE"));
foreach (var (edit, expected) in new (Action<JsonObject>, string)[]
{
(m => m["objectsBaseUrl"] = "https://ailleurs.example/objects/", "objectsBaseUrl"),
(m => m["version"] = "1.2/../x", "Version"),
(m => m["executable"] = "absent.exe", "exécutable"),
(m => m["files"]![0]!["path"] = "C:/Windows/x.dll", "Chemin non autorisé"),
(m => m["files"]![0]!["sha256"] = "zz", "empreinte"),
(m => m["formatVersion"] = 2, "formatVersion")
})
{
var node = JsonNode.Parse(bytes)!.AsObject();
edit(node);
errors.Clear();
Assert.Null(ManifestCheck.Check(System.Text.Encoding.UTF8.GetBytes(node.ToJsonString()), "EZR", null, errors));
Assert.Contains(errors, e => e.Contains(expected, StringComparison.OrdinalIgnoreCase));
}
}
[Fact]
public async Task Chunk_uploads_have_their_own_rate_limit_and_a_refusal_says_when_to_retry()
{
// Limite générale très basse : un envoi de 7 morceaux la dépasserait. Les morceaux ont leur propre
// limite (haute) : aucune pause pendant l'envoi.
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create();
var settings = Settings(manifest);
settings["LauncherApi:Limits:RequestsPerMinute"] = "8";
await using var api = await TestApi.StartAsync(settings);
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var reports = new List<UploadProgress>();
var result = await ReleaseUploader.PublishAsync(owner, "EZR", ReleaseUploader.Inspect(release.ManifestPath, "EZR"),
new Sync<UploadProgress>(reports.Add), null, default);
Assert.Equal("1.2", result.Version);
Assert.DoesNotContain(reports, r => r.Step.Contains("reprise dans"));
// La limite générale s'applique toujours au reste de l'API, et le refus annonce le délai (Retry-After).
HttpResponseMessage? limited = null;
for (var i = 0; i < 20 && limited is null; i++)
{
var response = await api.Http.GetAsync(ApiRoutes.Info);
if (response.StatusCode == HttpStatusCode.TooManyRequests) limited = response;
else response.Dispose();
}
Assert.NotNull(limited);
using (limited)
{
Assert.Equal(ApiErrorCodes.RateLimited, (await TestApi.ReadErrorAsync(limited!)).Code);
Assert.InRange(limited!.Headers.RetryAfter!.Delta!.Value, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
}
var error = await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().GetInfoAsync());
Assert.Equal(429, error.Status);
Assert.InRange(error.RetryAfter!.Value, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
}
[Fact]
public void After_a_refusal_the_upload_waits_just_as_long_as_needed()
{
static ApiException Limited(TimeSpan? retryAfter) => new(429, ApiErrorCodes.RateLimited, "") { RetryAfter = retryAfter };
// Délai annoncé par le serveur, arrondi à la seconde, entre 1 et 60 s.
Assert.Equal(TimeSpan.FromSeconds(12), ReleaseUploader.Backoff(1, Limited(TimeSpan.FromSeconds(11.2))));
Assert.Equal(TimeSpan.FromSeconds(1), ReleaseUploader.Backoff(1, Limited(TimeSpan.Zero)));
Assert.Equal(TimeSpan.FromSeconds(60), ReleaseUploader.Backoff(1, Limited(TimeSpan.FromMinutes(10))));
// Limite de nginx (pas de délai annoncé) : 1 s, puis le double à chaque refus consécutif, 30 s au plus.
Assert.Equal(new[] { 1, 2, 4, 8, 16, 30, 30, 30 },
Enumerable.Range(1, 8).Select(a => (int)ReleaseUploader.Backoff(a, Limited(null)).TotalSeconds).ToArray());
// Position décalée : reprise immédiate ; coupure réseau : attente croissante.
Assert.Equal(TimeSpan.Zero, ReleaseUploader.Backoff(3, new ApiException(409, ApiErrorCodes.OffsetMismatch, "")));
Assert.Equal(TimeSpan.FromSeconds(8), ReleaseUploader.Backoff(2, new ApiException(0, "unreachable", "")));
}
[Fact]
public void Object_folders_are_readable_by_nginx_without_asking_for_special_bits()
{
// Sur le VPS, systemd refuse au service toute demande de bit setgid (RestrictSUIDSGID=yes) : les dossiers
// sont créés sans bit spécial, le setgid et le groupe venant du dossier parent.
if (OperatingSystem.IsWindows()) return;
var game = Path.GetDirectoryName(ChangelogModuleTests.PublishedManifest())!;
const UnixFileMode special = UnixFileMode.SetUser | UnixFileMode.SetGroup | UnixFileMode.StickyBit;
var permissions = File.GetUnixFileMode(game) & ~special;
var objects = Path.Combine(game, "objects");
PublishedFiles.EnsurePublicDirectory(objects, game, keepSetGid: true);
Assert.Equal(permissions | UnixFileMode.SetGroup, File.GetUnixFileMode(objects)); // ses sous-dossiers auront le groupe de nginx
var leaf = Path.Combine(objects, "ab");
PublishedFiles.EnsurePublicDirectory(leaf, game, keepSetGid: false);
Assert.Equal(permissions, File.GetUnixFileMode(leaf) & ~special);
PublishedFiles.EnsurePublicDirectory(leaf, game, keepSetGid: false); // déjà là : rien ne change
Assert.Equal(permissions, File.GetUnixFileMode(leaf) & ~special);
}
}
@@ -251,6 +251,59 @@ public class AdminManagementTests
Assert.Equal("Paul", paul.Profile.DisplayName);
}
[Fact]
public async Task Every_admin_sees_their_own_account_and_withdraws_only_their_own_device_invitations()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var (laptopKey, paulId) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
await using var paul = await api.LoginAsync(laptopKey);
// Son compte, sans le droit de lire celui des autres.
var me = await paul.GetMyAccountAsync();
Assert.Equal(paulId, me.Admin.Id);
var laptop = Assert.Single(me.Admin.Keys);
Assert.Equal(laptopKey.KeyId, laptop.KeyId);
Assert.NotNull(laptop.Fingerprint);
Assert.Empty(me.Invitations);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.ListAdminsAsync())).Status);
// Ses invitations « nouvel appareil » (y compris celle d'un propriétaire), pas celles des autres.
var own = await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paulId });
var byOwner = await owner.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paulId });
var other = await owner.CreateInvitationAsync(new CreateInvitationRequest { DisplayName = "Autre", Role = AdminRoles.Editor });
me = await paul.GetMyAccountAsync();
Assert.Equal(new[] { own.Invitation.Id, byOwner.Invitation.Id }, me.Invitations.Select(i => i.Id).ToArray());
Assert.Equal(paulId, me.Invitations[0].CreatedById);
Assert.Equal(owner.Profile.Id, me.Invitations[1].CreatedById);
// Nouvel appareil enrôlé : en attente, empreinte masquée (lue sur le nouvel appareil).
using var desktopKey = TestApi.NewKey();
await api.CreateClient().EnrollAsync(own.Code, desktopKey, "PC fixe");
me = await paul.GetMyAccountAsync();
var pending = Assert.Single(me.Admin.Keys, k => k.KeyId == desktopKey.KeyId);
Assert.Equal(AdminStatus.Pending, pending.Status);
Assert.Null(pending.Fingerprint);
// Suppression : la sienne seulement ; un propriétaire supprime les autres.
var again = await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = paulId });
await paul.DeleteInvitationAsync(again.Invitation.Id);
var deleted = (await owner.GetAuditAsync(10)).Entries.First(e => e.Action == "admins.invite_deleted");
Assert.Equal("Paul", deleted.ActorName);
Assert.Equal("nouvel appareil pour Paul", deleted.Details);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.DeleteInvitationAsync(byOwner.Invitation.Id))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.DeleteInvitationAsync(other.Invitation.Id))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.DeleteInvitationAsync(Guid.NewGuid()))).Status);
await owner.DeleteInvitationAsync(byOwner.Invitation.Id);
Assert.Empty((await paul.GetMyAccountAsync()).Invitations);
Assert.Equal(404, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteInvitationAsync(byOwner.Invitation.Id))).Status);
// Compte révoqué : plus rien.
await owner.RevokeAdminAsync(paulId);
Assert.Equal(401, (await Assert.ThrowsAsync<ApiException>(() => paul.GetMyAccountAsync())).Status);
}
[Fact]
public async Task Admins_without_management_rights_have_few_invitations()
{
@@ -22,7 +22,9 @@ public class ChangelogModuleTests
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-changelog-tests", Guid.NewGuid().ToString("N"), "exovivalzombie");
Directory.CreateDirectory(directory);
if (!OperatingSystem.IsWindows()) // comme sur le VPS : dossier setgid (groupe www-data transmis aux nouveaux fichiers)
// Comme sur le VPS : dossier setgid (groupe www-data transmis aux nouveaux fichiers). Déjà hérité si le
// dossier des tests est lui-même setgid (tests lancés sous le filtre de RestrictSUIDSGID=yes).
if (!OperatingSystem.IsWindows() && !File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) | UnixFileMode.SetGroup);
var manifest = new GameManifest
{
@@ -0,0 +1,239 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Signing;
namespace GameLauncher.Tests.Server;
/// <summary>Signature du contenu côté serveur (phase 7) : catalogue, mise en ligne, version en ligne, trousseau.</summary>
public class SigningModuleTests
{
private static string PublicDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-signing-tests", Guid.NewGuid().ToString("N"), "catalog");
Directory.CreateDirectory(directory);
return directory;
}
/// <summary>Trousseau (racine de test) contenant la clé d'un administrateur, pour vérifier comme un launcher.</summary>
private static KeyringDocument KeyringOf(IAdminSigner admin, params string[] scopes) =>
SigningKit.Keyring(SigningKit.NewKey(), 1, new KeyringKey
{
KeyId = admin.KeyId,
PublicKey = Convert.ToBase64String(admin.PublicKey),
Name = "Propriétaire",
Scopes = scopes.Length == 0 ? SigningScopes.All.ToList() : scopes.ToList()
});
private static CatalogDocument Catalog(string tagline) => new()
{
Games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase) { ["EZR"] = new() { Tagline = tagline } }
};
[Fact]
public async Task The_catalog_is_published_with_the_signature_of_the_session_key()
{
var publicFile = Path.Combine(PublicDirectory(), "catalog.json");
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var catalog = Catalog("Signé");
var signature = await owner.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog));
await owner.PublishCatalogAsync(catalog with { Signature = signature }, 0);
// Le fichier public porte la signature, valide pour un launcher qui a cette clé dans son trousseau.
var published = JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!;
Assert.Equal(1, published.Revision);
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(published),
published.Signature, KeyringOf(ownerKey));
Assert.True(check.Valid, check.Reason);
// Signature d'un autre contenu, ou d'une autre clé que celle de la session : refusées.
var other = await owner.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(Catalog("Autre")));
var mismatch = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Signé 2") with { Signature = other }, 1));
Assert.Equal(ApiErrorCodes.SignatureInvalid, mismatch.Code);
using var stranger = SigningKit.NewKey();
var foreign = SigningKit.Sign(stranger, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(Catalog("Signé 2")));
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Signé 2") with { Signature = foreign }, 1))).Status);
// Date de signature trafiquée (bien plus tard) : refusée, elle bloquerait les mises à jour suivantes chez les joueurs.
var future = SigningKit.Sign(stranger, ContentKinds.Catalog, ContentKinds.CatalogId, "x") with { KeyId = ownerKey.KeyId, SignedAt = DateTimeOffset.UtcNow.AddDays(30) };
Assert.Equal(ApiErrorCodes.ClockSkew,
(await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Signé 2") with { Signature = future }, 1))).Code);
// Non signé : accepté tant que le serveur ne l'exige pas.
await owner.PublishCatalogAsync(Catalog("Non signé"), 1);
Assert.Null(JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!.Signature);
}
[Fact]
public async Task When_required_unsigned_publications_are_refused()
{
var publicFile = Path.Combine(PublicDirectory(), "catalog.json");
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = publicFile,
["LauncherApi:ContentSigning:Require"] = "true"
});
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Non signé"), 0));
Assert.Equal(ApiErrorCodes.SignatureRequired, refused.Code);
Assert.False(File.Exists(publicFile));
Assert.True((await owner.GetSigningStateAsync()).RequireSignatures);
}
[Fact]
public async Task A_published_version_carries_a_signature_that_survives_note_corrections()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 3, bigFileBytes: 100_000, archiveBytes: 5000);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
await ReleaseUploader.PublishAsync(owner, "EZR", ReleaseUploader.Inspect(release.ManifestPath, "EZR"), null, null, default);
ContentCheck Check()
{
var bytes = File.ReadAllBytes(manifest);
var signature = JsonNode.Parse(bytes)!["signature"]!.Deserialize<ContentSignature>(ApiJson.Options);
return ContentSigning.Verify(ContentKinds.Manifest, "EZR", ContentSigning.ManifestDigest(bytes), signature, KeyringOf(ownerKey));
}
var check = Check();
Assert.True(check.Valid, check.Reason);
// Correction des notes en ligne : la signature reste valide (les notes n'en font pas partie).
var state = await owner.GetChangelogAsync("EZR");
var entry = state.Entries.First(e => e.Version == "1.2");
entry.Title = "Version 1.2 (corrigée)";
await owner.SaveChangelogEntryAsync("EZR", entry, state.ManifestSha256);
Assert.Contains("corrigée", File.ReadAllText(manifest));
check = Check();
Assert.True(check.Valid, check.Reason);
var online = await owner.GetManifestSignatureAsync("EZR");
Assert.Equal("1.2", online.Version);
Assert.Equal(ownerKey.KeyId, online.Signature!.KeyId);
Assert.StartsWith("Propriétaire", online.SignerName);
}
[Fact]
public async Task The_online_version_is_signed_only_from_a_folder_with_the_same_content()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 2, bigFileBytes: 1000, archiveBytes: 500);
File.Copy(release.ManifestPath, manifest, overwrite: true); // version mise en ligne avec WinSCP, non signée
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
var online = await owner.GetManifestSignatureAsync("EZR");
Assert.Null(online.Signature);
Assert.Equal(local.ContentDigest, online.Digest);
// Dossier d'une autre version : refusé (le serveur compare le contenu).
var otherDigest = ContentSigning.ManifestDigest(Encoding.UTF8.GetBytes(SigningKit.ManifestJson("9.9")));
var otherSignature = await owner.SignContentAsync(ContentKinds.Manifest, "EZR", otherDigest);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() =>
owner.SignOnlineManifestAsync("EZR", new ManifestSignRequest { Digest = otherDigest, Signature = otherSignature }))).Status);
// Bon condensé, mais signature d'autre chose : refusée.
Assert.Equal(ApiErrorCodes.SignatureInvalid, (await Assert.ThrowsAsync<ApiException>(() =>
owner.SignOnlineManifestAsync("EZR", new ManifestSignRequest { Digest = local.ContentDigest, Signature = otherSignature }))).Code);
Assert.Null(JsonNode.Parse(File.ReadAllBytes(manifest))!["signature"]);
var signature = await owner.SignContentAsync(ContentKinds.Manifest, "EZR", local.ContentDigest);
var signed = await owner.SignOnlineManifestAsync("EZR", new ManifestSignRequest { Digest = local.ContentDigest, Signature = signature });
Assert.Equal(ownerKey.KeyId, signed.Signature!.KeyId);
var bytes = File.ReadAllBytes(manifest);
var check = ContentSigning.Verify(ContentKinds.Manifest, "EZR", ContentSigning.ManifestDigest(bytes),
JsonNode.Parse(bytes)!["signature"]!.Deserialize<ContentSignature>(ApiJson.Options), KeyringOf(ownerKey));
Assert.True(check.Valid, check.Reason);
// Un éditeur (sans releases.publish) ne signe pas de version.
var (editorKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
await using var editor = await api.LoginAsync(editorKey);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetManifestSignatureAsync("EZR"))).Status);
}
[Fact]
public async Task Only_a_signed_and_newer_keyring_is_published_next_to_the_catalog()
{
var directory = PublicDirectory();
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
["LauncherApi:Games:EZR:ManifestFile"] = manifest
});
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
using var root = SigningKit.NewKey();
var entry = new KeyringKey
{
KeyId = ownerKey.KeyId,
PublicKey = Convert.ToBase64String(ownerKey.PublicKey),
Name = "Propriétaire — PC",
Scopes = SigningScopes.All.ToList()
};
var unsigned = new KeyringDocument { Sequence = 1, IssuedAt = DateTimeOffset.UtcNow, Keys = new() { entry } };
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishKeyringAsync(unsigned))).Status);
await owner.PublishKeyringAsync(SigningKit.Keyring(root, 1, entry));
var publicFile = Path.Combine(directory, "keyring.json");
var published = Keyring.Parse(File.ReadAllBytes(publicFile))!;
Assert.True(Keyring.VerifyRoot(published, new[] { SigningKit.Root(root) }, out var error), error);
if (!OperatingSystem.IsWindows())
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
File.GetUnixFileMode(publicFile));
// Même numéro ou plus ancien : refusé (un launcher refuserait de revenir en arrière).
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishKeyringAsync(SigningKit.Keyring(root, 1, entry)))).Status);
await owner.PublishKeyringAsync(SigningKit.Keyring(root, 2, entry));
var state = await owner.GetSigningStateAsync();
Assert.Equal(2, state.Keyring!.Sequence);
Assert.EndsWith("/catalog/keyring.json", state.KeyringPublicUrl);
Assert.True(state.KeyringPublishingEnabled);
Assert.Equal("1.1", Assert.Single(state.Manifests).Version);
Assert.Null(state.Manifests[0].Signature);
Assert.NotNull(state.Catalog);
Assert.Contains((await owner.GetAuditAsync(10)).Entries, e => e.Action == "keyring.publish" && e.Target == "trousseau n° 2");
// Réservé aux propriétaires ; clés publiques exposées seulement pour les clés actives.
var (adminKey, adminId) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
await using var paul = await api.LoginAsync(adminKey);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.GetSigningStateAsync())).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.PublishKeyringAsync(SigningKit.Keyring(root, 3, entry)))).Status);
var device = await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = adminId });
await api.CreateClient().EnrollAsync(device.Code, TestApi.NewKey(), "nouveau PC");
var keys = (await owner.ListAdminsAsync()).Single(a => a.Id == adminId).Keys;
Assert.All(keys, k => Assert.Equal(k.Status == AdminStatus.Active, k.PublicKey is not null));
}
[Fact]
public async Task Without_a_catalog_folder_the_keyring_cannot_be_published()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
using var root = SigningKit.NewKey();
var keyring = SigningKit.Keyring(root, 1, new KeyringKey
{
KeyId = ownerKey.KeyId, PublicKey = Convert.ToBase64String(ownerKey.PublicKey), Name = "P", Scopes = new() { SigningScopes.Releases }
});
Assert.Equal(ApiErrorCodes.NotConfigured, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishKeyringAsync(keyring))).Code);
Assert.False((await owner.GetSigningStateAsync()).KeyringPublishingEnabled);
}
}
@@ -0,0 +1,207 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Tests.Signing;
/// <summary>Condensés canoniques, signatures du contenu et trousseau (phase 7).</summary>
public class ContentSigningTests
{
private static string Digest(string json) => ContentSigning.ManifestDigest(Encoding.UTF8.GetBytes(json));
[Fact]
public void The_manifest_digest_covers_what_is_installed_and_run_but_not_the_notes()
{
var json = SigningKit.ManifestJson();
var digest = Digest(json);
// Mise en forme, ordre des fichiers, notes, date, signature : sans effet.
var node = JsonNode.Parse(json)!.AsObject();
var files = node["files"]!.AsArray();
var first = files[0]!.DeepClone();
files.RemoveAt(0);
files.Add(first);
node["changelog"] = new JsonArray(new JsonObject { ["version"] = "1.2", ["title"] = "Notes corrigées en ligne" });
node["releaseDate"] = "2027-01-01T00:00:00Z";
node["signature"] = new JsonObject { ["keyId"] = "0123456789abcdef", ["value"] = "x" };
Assert.Equal(digest, Digest(node.ToJsonString(new JsonSerializerOptions { WriteIndented = false })));
// Ce qui est installé ou exécuté : tout changement change le condensé.
Assert.NotEqual(digest, Digest(SigningKit.ManifestJson(executableSha: "bb")));
Assert.NotEqual(digest, Digest(SigningKit.ManifestJson(version: "1.3")));
foreach (var (path, value) in new (string, JsonNode)[]
{
("executable", "Data/level1.pak"), ("launchArguments", "-cheat"), ("objectsBaseUrl", "https://ailleurs.example/objects/"),
("gameId", "AUTRE")
})
{
var changed = JsonNode.Parse(json)!.AsObject();
changed[path] = value;
Assert.NotEqual(digest, Digest(changed.ToJsonString()));
}
var archive = JsonNode.Parse(json)!.AsObject();
archive["archive"]!["url"] = "https://ailleurs.example/piege.zip";
Assert.NotEqual(digest, Digest(archive.ToJsonString()));
}
[Fact]
public void The_player_computes_the_same_digest_from_its_own_manifest_model()
{
// Le launcher des joueurs lit le manifest avec son propre modèle (GameManifest) : même condensé.
var json = SigningKit.ManifestJson();
var manifest = JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)!;
Assert.Equal(Digest(json), ContentSigning.ManifestDigest(manifest.ToSignedContent()));
// Arguments vides ou absents, base des objets absente ou par défaut : équivalents.
var node = JsonNode.Parse(json)!.AsObject();
node.Remove("objectsBaseUrl");
node["launchArguments"] = "";
var variant = JsonSerializer.Deserialize<GameManifest>(node.ToJsonString(), JsonDefaults.Options)!;
Assert.Equal(Digest(json), ContentSigning.ManifestDigest(variant.ToSignedContent()));
}
[Fact]
public void The_catalog_digest_ignores_what_the_server_sets()
{
var catalog = new CatalogDocument
{
Launcher = new CatalogLauncher { LauncherName = "Lanisys", GameOrder = new() { "EZR", "B" } },
Games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase)
{
["EZR"] = new() { GameName = "Exovival", Links = new() { new CatalogLink { Kind = "discord", Url = "https://discord.gg/abc" } } },
["B"] = new() { Tagline = "Jeu B" }
}
};
var digest = ContentSigning.CatalogDigest(catalog);
var reordered = catalog with
{
Revision = 42,
UpdatedAt = DateTimeOffset.UtcNow,
Signature = new ContentSignature { KeyId = "0123456789abcdef" },
Games = new Dictionary<string, CatalogGame> { ["B"] = catalog.Games["B"], ["EZR"] = catalog.Games["EZR"] }
};
Assert.Equal(digest, ContentSigning.CatalogDigest(reordered));
Assert.False(ContentSigning.HasDuplicateGames(catalog));
// Identifiants tels que publiés (pas de mise en minuscules) : deux entrées ne différant que par la casse ne
// peuvent pas se confondre dans le condensé, et un tel catalogue est repéré (refusé par les launchers).
Assert.NotEqual(digest, ContentSigning.CatalogDigest(catalog with
{
Games = new Dictionary<string, CatalogGame> { ["b"] = catalog.Games["B"], ["ezr"] = catalog.Games["EZR"] }
}));
var duplicated = catalog with
{
Games = new Dictionary<string, CatalogGame>(StringComparer.Ordinal)
{
["EZR"] = catalog.Games["EZR"], ["ezr"] = new() { WebsiteUrl = "https://launcher.example/piege" }, ["B"] = catalog.Games["B"]
}
};
Assert.True(ContentSigning.HasDuplicateGames(duplicated));
// Lien modifié (hameçonnage), ordre des jeux : refusés.
var phishing = catalog with
{
Games = new Dictionary<string, CatalogGame>(catalog.Games)
{
["EZR"] = catalog.Games["EZR"] with { Links = new() { new CatalogLink { Kind = "discord", Url = "https://discord.gg/piege" } } }
}
};
Assert.NotEqual(digest, ContentSigning.CatalogDigest(phishing));
Assert.NotEqual(digest, ContentSigning.CatalogDigest(catalog with { Launcher = catalog.Launcher with { GameOrder = new() { "B", "EZR" } } }));
}
[Fact]
public void A_signature_is_only_valid_for_its_content_kind_game_and_authorized_key()
{
using var root = SigningKit.NewKey();
using var publisher = SigningKit.NewKey();
using var editor = SigningKit.NewKey();
using var stranger = SigningKit.NewKey();
var keyring = SigningKit.Keyring(root, 1,
SigningKit.Entry(publisher, "Nicolas — PC fixe", SigningScopes.Releases, SigningScopes.Catalog),
SigningKit.Entry(editor, "Paul — portable", SigningScopes.Catalog));
var digest = Digest(SigningKit.ManifestJson());
var good = SigningKit.Sign(publisher, ContentKinds.Manifest, "EZR", digest);
var check = ContentSigning.Verify(ContentKinds.Manifest, "ezr", digest, good, keyring); // identifiant : casse indifférente
Assert.True(check.Valid, check.Reason);
Assert.Equal("Nicolas — PC fixe", check.Key!.Name);
Assert.False(ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, null, keyring).Valid);
Assert.Contains("SIGNATURE INVALIDE", ContentSigning.Verify(ContentKinds.Manifest, "EZR", Digest(SigningKit.ManifestJson(version: "0.9")), good, keyring).Reason);
Assert.False(ContentSigning.Verify(ContentKinds.Manifest, "AUTREJEU", digest, good, keyring).Valid); // manifest d'un autre jeu
Assert.False(ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, digest, good, keyring).Valid); // autre type
Assert.Contains("n'est pas autorisée",
ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, SigningKit.Sign(editor, ContentKinds.Manifest, "EZR", digest), keyring).Reason);
Assert.Contains("absente du trousseau",
ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, SigningKit.Sign(stranger, ContentKinds.Manifest, "EZR", digest), keyring).Reason);
// Date modifiée après signature : refusée (elle fait partie du message signé).
Assert.False(ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, good with { SignedAt = good.SignedAt.AddDays(1) }, keyring).Valid);
}
[Fact]
public void The_keyring_is_trusted_only_when_signed_by_a_root_key_of_the_launcher()
{
using var root = SigningKit.NewKey();
using var otherRoot = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
using var intruder = SigningKit.NewKey();
var keyring = SigningKit.Keyring(root, 3, SigningKit.Entry(admin, "Nicolas"));
var roots = new[] { SigningKit.Root(root) };
Assert.True(Keyring.VerifyRoot(keyring, roots, out var error), error);
// Relu depuis le fichier publié : toujours valide.
var reread = Keyring.Parse(Encoding.UTF8.GetBytes(Keyring.Serialize(keyring)))!;
Assert.True(Keyring.VerifyRoot(reread, roots, out error), error);
// Clé ajoutée par un serveur compromis, numéro modifié, signature d'une autre racine : refusés.
Assert.False(Keyring.VerifyRoot(keyring with { Keys = keyring.Keys.Append(SigningKit.Entry(intruder, "Pirate")).ToList() }, roots, out error));
Assert.Contains("INVALIDE", error);
Assert.False(Keyring.VerifyRoot(keyring with { Sequence = 99 }, roots, out _));
Assert.False(Keyring.VerifyRoot(SigningKit.Keyring(otherRoot, 3, SigningKit.Entry(admin, "Nicolas")), roots, out error));
Assert.Contains("inconnue", error);
Assert.False(Keyring.VerifyRoot(keyring with { Signature = null }, roots, out _));
// Structure : identifiant ne correspondant pas à la clé, autorisation inconnue.
var errors = new List<string>();
Assert.False(Keyring.Validate(keyring with
{
Keys = new() { SigningKit.Entry(admin, "Nicolas") with { KeyId = SigningKit.KeyId(intruder) }, SigningKit.Entry(intruder, "X", "admin") }
}, errors));
Assert.Contains(errors, e => e.Contains("ne correspond pas"));
Assert.Contains(errors, e => e.Contains("autorisations"));
// Membres absents (« keys »: null, « scopes »: null…) : refusés sans exception.
foreach (var json in new[]
{
"""{ "sequence": 1, "keys": null }""",
"""{ "sequence": 1, "keys": [ null ] }""",
"""{ "sequence": 1, "keys": [ { "keyId": "0123456789abcdef", "publicKey": null, "name": "X", "scopes": null } ] }"""
})
{
var parsed = Keyring.Parse(Encoding.UTF8.GetBytes(json))!;
errors.Clear();
Assert.False(Keyring.Validate(parsed, errors));
Assert.False(Keyring.VerifyRoot(parsed, roots, out _));
}
// Nom trompeur (inversion du sens d'écriture, caractère invisible) : refusé.
errors.Clear();
Assert.False(Keyring.Validate(SigningKit.Keyring(root, 4, SigningKit.Entry(admin, "Nicolas \u202Eexe.cod")), errors));
Assert.False(Keyring.IsDisplaySafe("Paul\u200B"));
Assert.True(Keyring.IsDisplaySafe("Nicolas — PC fixe"));
}
[Fact]
public void A_manifest_with_missing_members_still_has_a_digest()
{
// Manifest incomplet ou malformé (membres null) : condensé calculé, jamais d'exception.
var digest = Digest("""{ "gameId": "EZR", "version": "1.0", "files": [ null ], "archive": null }""");
Assert.Equal(64, digest.Length);
Assert.NotEqual(digest, Digest(SigningKit.ManifestJson()));
}
}
@@ -0,0 +1,345 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
using GameLauncher.Tests.Admin;
namespace GameLauncher.Tests.Signing;
/// <summary>Vérification du contenu par le launcher des joueurs (« contentSigning »).</summary>
public class ContentTrustTests
{
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
private const string CatalogUrl = "https://launcher.example/catalog/catalog.json";
/// <summary>Serveur simulé : trousseau servi (ou indisponible).</summary>
private sealed class FakeServer
{
public byte[]? Keyring { get; set; }
public int Requests { get; private set; }
/// <summary>Erreur levée à la place de la réponse (délai dépassé, bogue…).</summary>
public Exception? Failure { get; set; }
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
{
Requests++;
if (Failure is not null) throw Failure;
return Keyring is { } bytes ? Task.FromResult(bytes) : throw new HttpRequestException("hors ligne");
}
public void Serve(KeyringDocument keyring) => Keyring = Encoding.UTF8.GetBytes(GameLauncher.Contracts.Signing.Keyring.Serialize(keyring));
}
private static ContentTrustService Trust(FakeServer server, string data, ECDsa root, ContentSigningMode mode, long minSequence = 0,
TimeProvider? time = null) =>
new(server.Download, data, KeyringUrl, new[] { SigningKit.Root(root) }, mode, minSequence, time);
/// <summary>Horloge avancée à la main.</summary>
private sealed class ManualClock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
private static byte[] Bytes(CatalogDocument c) => Encoding.UTF8.GetBytes(JsonSerializer.Serialize(c, ApiJson.Options));
private static GameManifest Manifest(string version, ECDsa? signer, DateTimeOffset? signedAt = null)
{
var manifest = JsonSerializer.Deserialize<GameManifest>(SigningKit.ManifestJson(version), JsonDefaults.Options)!;
if (signer is not null)
manifest.Signature = SigningKit.Sign(signer, ContentKinds.Manifest, "EZR", ContentSigning.ManifestDigest(manifest.ToSignedContent()), signedAt);
return manifest;
}
[Fact]
public async Task In_require_mode_only_signed_and_newer_manifests_are_accepted()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(admin, "Nicolas")));
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
var now = DateTimeOffset.UtcNow;
await trust.VerifyManifestAsync(Manifest("1.2", admin, now), "EZR");
var unsigned = await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", null), "EZR"));
Assert.Contains("non signé", unsigned.Message);
// Contenu modifié après signature (archive remplacée par un serveur compromis).
var tampered = Manifest("1.2", admin, now);
tampered.Archive.Sha256 = new string('d', 64);
Assert.Contains("SIGNATURE INVALIDE", (await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(tampered, "EZR"))).Message);
// Manifest d'un autre jeu servi à la place de celui-ci.
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", admin, now), "AUTRE"));
// Retour en arrière : une version légitime mais signée plus tôt est refusée, même après redémarrage.
var older = Manifest("1.1", admin, now.AddDays(-7));
Assert.Contains("retour en arrière", (await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(older, "EZR"))).Message);
var restarted = Trust(server, folder.Path, root, ContentSigningMode.Require);
await Assert.ThrowsAsync<ContentSecurityException>(() => restarted.VerifyManifestAsync(older, "EZR"));
await restarted.VerifyManifestAsync(Manifest("1.3", admin, now.AddMinutes(5)), "EZR");
}
[Fact]
public async Task In_warn_mode_problems_are_logged_and_the_content_is_used()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
var server = new FakeServer(); // trousseau indisponible
var trust = Trust(server, folder.Path, root, ContentSigningMode.Warn);
await trust.VerifyManifestAsync(Manifest("1.2", null), "EZR");
var strict = Trust(server, folder.Path, root, ContentSigningMode.Require);
Assert.Contains("trousseau indisponible",
(await Assert.ThrowsAsync<ContentSecurityException>(() => strict.VerifyManifestAsync(Manifest("1.2", null), "EZR"))).Message);
}
[Fact]
public async Task A_forged_or_older_keyring_never_replaces_the_one_already_seen()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var forgedRoot = SigningKit.NewKey();
using var nicolas = SigningKit.NewKey();
using var revoked = SigningKit.NewKey();
var server = new FakeServer();
// Trousseau n° 2 vu (la clé « revoked » en a été retirée).
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
Assert.Equal(2, (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Sequence);
// Le serveur ressert l'ancien n° 1 (qui contenait la clé retirée) : refusé, le n° 2 reste utilisé.
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(revoked, "Ancien PC")));
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
Assert.Equal(2, (await trust.GetKeyringAsync())!.Sequence);
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", revoked), "EZR"));
// Trousseau signé par une autre racine, ou même numéro au contenu différent : refusés.
server.Serve(SigningKit.Keyring(forgedRoot, 9, SigningKit.Entry(revoked, "Pirate")));
Assert.Equal(2, (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Sequence);
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(revoked, "Pirate")));
Assert.Equal("Nicolas", (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Keys[0].Name);
// Hors ligne : le dernier trousseau vu est utilisé.
server.Keyring = null;
var offline = Trust(server, folder.Path, root, ContentSigningMode.Require);
await offline.VerifyManifestAsync(Manifest("1.2", nicolas), "EZR");
// Fichier d'état modifié à la main (clé ajoutée) : le trousseau en cache est rejeté.
var state = File.ReadAllText(offline.StatePath).Replace("Nicolas", "Nicolas modifié");
File.WriteAllText(offline.StatePath, state);
Assert.Null(await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync());
}
[Fact]
public async Task An_unsigned_catalog_is_ignored_in_require_mode()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var editor = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(editor, "Paul", SigningScopes.Catalog)));
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
var catalog = new CatalogDocument
{
Revision = 3,
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Signé" } }
};
var signed = catalog with { Signature = SigningKit.Sign(editor, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog)) };
var unsignedService = new CatalogService((_, _, _) => Task.FromResult(Bytes(catalog)), folder.Path, new Uri(CatalogUrl), trust);
Assert.Null(await unsignedService.FetchAsync());
Assert.Null(unsignedService.LoadCached()); // rien mis en cache
var signedService = new CatalogService((_, _, _) => Task.FromResult(Bytes(signed)), folder.Path, new Uri(CatalogUrl), trust);
Assert.Equal("Signé", (await signedService.FetchAsync())!.Games["EZR"].Tagline);
// Lien ajouté après signature : refusé, le catalogue en cache est conservé.
var tampered = signed with
{
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Signé", WebsiteUrl = "https://launcher.example/piege" } }
};
Assert.Null(await new CatalogService((_, _, _) => Task.FromResult(Bytes(tampered)), folder.Path, new Uri(CatalogUrl), trust).FetchAsync());
Assert.Equal("Signé", signedService.LoadCached()!.Games["EZR"].Tagline);
}
[Fact]
public async Task A_catalog_with_case_colliding_games_is_refused_even_if_signed()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var editor = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(editor, "Paul", SigningScopes.Catalog)));
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
// « EZR » signé, « ezr » ajouté : le joueur pourrait retenir l'un ou l'autre selon sa lecture.
var catalog = new CatalogDocument
{
Revision = 5,
Games = new Dictionary<string, CatalogGame>(StringComparer.Ordinal)
{
["EZR"] = new() { Tagline = "Officiel" },
["ezr"] = new() { Tagline = "Piège", WebsiteUrl = "https://launcher.example/piege" }
}
};
var signed = catalog with { Signature = SigningKit.Sign(editor, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog)) };
Assert.False(await trust.VerifyCatalogAsync(signed));
Assert.Null(await new CatalogService((_, _, _) => Task.FromResult(Bytes(signed)), folder.Path, new Uri(CatalogUrl), trust).FetchAsync());
}
[Fact]
public async Task A_keyring_failure_never_escapes_and_warn_mode_keeps_working()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
var server = new FakeServer { Failure = new TimeoutException("délai dépassé") };
var warn = Trust(server, folder.Path, root, ContentSigningMode.Warn);
Assert.Null(await warn.GetKeyringAsync());
await warn.VerifyManifestAsync(Manifest("1.2", null), "EZR"); // signalé, utilisé
server.Failure = new NullReferenceException("bogue");
var strict = Trust(server, folder.Path, root, ContentSigningMode.Require);
Assert.Contains("trousseau indisponible",
(await Assert.ThrowsAsync<ContentSecurityException>(() => strict.VerifyManifestAsync(Manifest("1.2", null), "EZR"))).Message);
}
[Fact]
public async Task A_keyring_older_than_the_embedded_minimum_is_refused_even_for_a_new_installation()
{
using var root = SigningKit.NewKey();
using var revoked = SigningKit.NewKey();
using var nicolas = SigningKit.NewKey();
var server = new FakeServer();
// Ancien trousseau n° 1, signé par la racine, qui contenait encore une clé compromise.
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(revoked, "Ancien PC"), SigningKit.Entry(nicolas, "Nicolas")));
using (var fresh = new TempFolder())
{
var trust = Trust(server, fresh.Path, root, ContentSigningMode.Require, minSequence: 2);
Assert.Null(await trust.GetKeyringAsync());
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", revoked), "EZR"));
}
// Un cache plus ancien que le minimum (launcher mis à jour) n'est plus utilisé non plus.
using var folder = new TempFolder();
Assert.Equal(1, (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Sequence);
server.Keyring = null;
Assert.Null(await Trust(server, folder.Path, root, ContentSigningMode.Require, minSequence: 2).GetKeyringAsync());
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
var current = Trust(server, folder.Path, root, ContentSigningMode.Require, minSequence: 2);
await current.VerifyManifestAsync(Manifest("1.2", nicolas), "EZR");
}
[Fact]
public async Task A_key_added_while_the_launcher_runs_is_found_by_downloading_the_keyring_again()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var nicolas = SigningKit.NewKey();
using var paul = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas")));
var clock = new ManualClock();
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
await trust.VerifyManifestAsync(Manifest("1.2", nicolas), "EZR");
Assert.Equal(1, server.Requests);
// Nouvelle clé publiée depuis : nouvel essai limité dans le temps (pas un téléchargement par contenu).
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(paul, "Paul")));
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.3", paul), "EZR"));
Assert.Equal(1, server.Requests);
clock.Now += TimeSpan.FromMinutes(6);
await trust.VerifyManifestAsync(Manifest("1.3", paul), "EZR");
Assert.Equal(2, server.Requests);
// Clé inconnue de nouveau juste après (contenu signé par une clé étrangère) : pas de nouveau téléchargement.
using var stranger = SigningKit.NewKey();
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.4", stranger), "EZR"));
Assert.Equal(2, server.Requests);
}
[Fact]
public async Task A_signature_dated_far_in_the_future_does_not_block_later_releases()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(admin, "Nicolas")));
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
// Horloge déréglée lors d'une signature : date retenue plafonnée à demain.
await trust.VerifyManifestAsync(Manifest("1.2", admin, DateTimeOffset.UtcNow.AddYears(5)), "EZR");
// Une version signée correctement dans deux jours reste acceptée (pas cinq ans de blocage).
await trust.VerifyManifestAsync(Manifest("1.3", admin, DateTimeOffset.UtcNow.AddDays(2)), "EZR");
}
[Fact]
public async Task The_cached_catalog_is_verified_again_at_startup_in_require_mode()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var editor = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(editor, "Paul", SigningScopes.Catalog)));
var catalog = new CatalogDocument { Revision = 2, Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Avant" } } };
// Catalogue en cache écrit en mode « warn » (non signé) : ignoré au démarrage en mode « require ».
var warn = Trust(server, folder.Path, root, ContentSigningMode.Warn);
await warn.GetKeyringAsync();
Assert.NotNull(await new CatalogService((_, _, _) => Task.FromResult(Bytes(catalog)), folder.Path, new Uri(CatalogUrl), warn).FetchAsync());
var offline = new FakeServer();
var strictService = new CatalogService((_, _, _) => throw new HttpRequestException("hors ligne"), folder.Path, new Uri(CatalogUrl),
Trust(offline, folder.Path, root, ContentSigningMode.Require));
Assert.Null(strictService.LoadCached());
Assert.Equal(0, offline.Requests); // vérification hors ligne, avec le trousseau en cache
// Signé : accepté ; modifié ensuite sur le disque : ignoré.
var signed = catalog with { Signature = SigningKit.Sign(editor, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog)) };
Assert.NotNull(await new CatalogService((_, _, _) => Task.FromResult(Bytes(signed)), folder.Path, new Uri(CatalogUrl), warn).FetchAsync());
Assert.Equal("Avant", strictService.LoadCached()!.Games["EZR"].Tagline);
File.WriteAllText(strictService.CachePath, File.ReadAllText(strictService.CachePath).Replace("Avant", "Modifié"));
Assert.Null(strictService.LoadCached());
}
[Fact]
public void Launcher_json_declares_the_keyring_and_the_mode()
{
LauncherCatalog Parse(string extra) => LauncherCatalog.Parse($$"""
{ "launcherName": "L", "publisher": "P", {{extra}}
"games": [ { "gameId": "EZR", "gameName": "Exovival", "manifestUrl": "https://launcher.example/ezr/manifest.json" } ] }
""");
Assert.Equal(ContentSigningMode.Off, Parse("").ContentSigning);
var warn = Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\",");
Assert.Equal(ContentSigningMode.Warn, warn.ContentSigning);
Assert.Equal("https://launcher.example/catalog/keyring.json", warn.KeyringUrl);
Assert.Equal(ContentSigningMode.Require,
Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\", \"contentSigning\": \"require\",").ContentSigning);
var noKeyring = Parse("\"contentSigning\": \"require\",");
Assert.Equal(ContentSigningMode.Require, noKeyring.ContentSigning); // jamais assoupli
Assert.NotNull(noKeyring.ContentSigningError);
var invalid = Parse("\"keyringUrl\": \"http://launcher.example/k.json\", \"contentSigning\": \"peut-être\",");
Assert.Null(invalid.KeyringUrl);
Assert.NotNull(invalid.KeyringUrlError);
Assert.Equal(ContentSigningMode.Warn, invalid.ContentSigning);
Assert.Equal(0, warn.KeyringMinSequence);
Assert.Equal(3, Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\", \"keyringMinSequence\": 3,").KeyringMinSequence);
var badMin = Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\", \"keyringMinSequence\": -1,");
Assert.Equal(0, badMin.KeyringMinSequence);
Assert.NotNull(badMin.ContentSigningError);
}
}
@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Tests.Signing;
/// <summary>Clés et trousseaux de test (clé racine comprise, comme celle des mises à jour du launcher).</summary>
public static class SigningKit
{
public static ECDsa NewKey() => ECDsa.Create(ECCurve.NamedCurves.nistP256);
public static string KeyId(ECDsa key) => AdminKeys.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
/// <summary>Clé racine telle que le launcher la connaît (identifiant, SPKI).</summary>
public static (string KeyId, byte[] PublicKey) Root(ECDsa root) => (KeyId(root), root.ExportSubjectPublicKeyInfo());
public static KeyringKey Entry(ECDsa key, string name, params string[] scopes) => new()
{
KeyId = KeyId(key),
PublicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()),
Name = name,
Scopes = scopes.Length == 0 ? SigningScopes.All.ToList() : scopes.ToList()
};
public static KeyringDocument Keyring(ECDsa root, long sequence, params KeyringKey[] keys) =>
GameLauncher.Contracts.Signing.Keyring.Sign(new KeyringDocument
{
Sequence = sequence,
IssuedAt = DateTimeOffset.UtcNow,
Keys = keys.ToList()
}, root);
public static ContentSignature Sign(ECDsa key, string kind, string id, string digest, DateTimeOffset? at = null)
{
var signedAt = ContentSigning.Truncate(at ?? DateTimeOffset.UtcNow);
var value = AdminKeys.Sign(key, ContentSigning.Message(kind, id, signedAt, KeyId(key), digest));
return new ContentSignature { KeyId = KeyId(key), SignedAt = signedAt, Value = Convert.ToBase64String(value) };
}
/// <summary>Manifest d'exemple (JSON, comme produit par ManifestBuilder).</summary>
public static string ManifestJson(string version = "1.2", string executableSha = "aa", string changelogTitle = "Version 1.2") => $$"""
{
"formatVersion": 1,
"gameId": "EZR",
"version": "{{version}}",
"releaseDate": "2026-10-31T18:00:00+01:00",
"executable": "Exovival Zombie Reborn.exe",
"archive": { "url": "EZR-{{version}}.zip", "size": 1234, "sha256": "{{new string('c', 64)}}" },
"objectsBaseUrl": "objects/",
"files": [
{ "path": "Exovival Zombie Reborn.exe", "size": 1000, "sha256": "{{executableSha.PadRight(64, 'a')}}" },
{ "path": "Data/level1.pak", "size": 234, "sha256": "{{new string('B', 64)}}" }
],
"changelog": [ { "version": "{{version}}", "title": "{{changelogTitle}}", "changes": [ "Nouveautés." ] } ]
}
""";
}
+126
View File
@@ -3,6 +3,8 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
@@ -43,6 +45,7 @@ public static class Program
case "keygen": KeyGen(options); return 0;
case "publish": await PublishAsync(options); return 0;
case "verify": return await VerifyAsync(options) ? 0 : 3;
case "sign-keyring": return SignKeyring(options);
default: return Usage();
}
}
@@ -378,6 +381,122 @@ public static class Program
return text.ToString();
}
// ------------------------------------------------------------------ sign-keyring
/// <summary>
/// Signe une demande de trousseau (exportée par la page « Signature » du menu d'administration) avec la clé
/// RACINE (celle des mises à jour du launcher). Hors ligne : cette clé n'est jamais ouverte par le launcher ni
/// envoyée au serveur ; le trousseau signé est ensuite publié depuis le menu d'administration.
/// </summary>
private static int SignKeyring(Options o)
{
var input = Path.GetFullPath(o.Require("--in"));
var keyPath = Path.GetFullPath(o.Get("--key") ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".launcher-keys", PrivateKeyFileName));
var roots = o.Get("--public-key") is { } publicKeys
? UpdateSignature.ParsePublicKeys(File.ReadAllText(publicKeys)).Select(k => (k.KeyId, k.SubjectPublicKeyInfo)).ToList()
: null;
var request = Keyring.Parse(File.ReadAllBytes(input)) ?? throw new InvalidDataException("Demande de trousseau vide.");
var errors = new List<string>();
if (!Keyring.Validate(request, errors))
throw new InvalidDataException("Demande de trousseau invalide :" + Environment.NewLine + " " + string.Join(Environment.NewLine + " ", errors));
// Nom par défaut numéroté : les trousseaux signés successifs restent côte à côte (comparaison au suivant).
var output = Path.GetFullPath(o.Get("--out") ?? Path.Combine(Path.GetDirectoryName(input)!, $"keyring-{request.Sequence}.json"));
// Trousseau précédent (signé, vérifié) : la demande vient du serveur, elle n'est PAS digne de confiance en
// elle-même. Les clés nouvelles ou aux autorisations élargies sont signalées pour être vérifiées une à une.
var previousPath = o.Get("--previous") is { } explicitPrevious
? Path.GetFullPath(explicitPrevious)
: FindPreviousKeyring(Path.GetDirectoryName(input)!, request.Sequence, roots);
KeyringDocument? previous = null;
if (previousPath is not null)
{
previous = Keyring.Parse(File.ReadAllBytes(previousPath)) ?? throw new InvalidDataException($"{previousPath} est vide.");
if (previous.Signature is null)
throw new InvalidDataException($"{Path.GetFileName(previousPath)} n'est pas un trousseau signé.");
if (roots is not null && !Keyring.VerifyRoot(previous, roots, out var previousError))
throw new CryptographicException($"{Path.GetFileName(previousPath)} : {previousError}");
if (request.Sequence <= previous.Sequence)
throw new InvalidDataException($"Demande n° {request.Sequence} : le trousseau précédent porte déjà le n° {previous.Sequence}.");
}
Console.WriteLine($"Trousseau n° {request.Sequence} du {request.IssuedAt.ToLocalTime():dd/MM/yyyy HH:mm} — {request.Keys.Count} clé(s) :");
Console.WriteLine(previous is null
? " (aucun trousseau signé précédent trouvé à côté de la demande : TOUTES les clés sont à vérifier)"
: $" (comparé au trousseau n° {previous.Sequence} : {Path.GetFileName(previousPath)})");
var previousKeys = previous?.Keys.ToDictionary(k => k.KeyId, StringComparer.Ordinal) ?? new Dictionary<string, KeyringKey>();
var toCheck = 0;
foreach (var key in request.Keys.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase))
{
var fingerprint = AdminKeys.ComputeFingerprint(Convert.FromBase64String(key.PublicKey));
string? flag = null;
if (!previousKeys.TryGetValue(key.KeyId, out var old) || old.PublicKey != key.PublicKey) flag = "NOUVELLE CLÉ";
else if (key.Scopes.Except(old.Scopes, StringComparer.Ordinal).Any()) flag = "AUTORISATIONS ÉLARGIES";
if (flag is not null) toCheck++;
Console.WriteLine($" • {key.Name}");
var line = $" empreinte {fingerprint} · clé {key.KeyId} · {string.Join(", ", key.Scopes.Select(SigningScopes.DisplayName))}";
if (flag is null) Console.WriteLine(line);
else Warn($"{line} ← {flag}");
}
foreach (var removed in previousKeys.Values.Where(k => request.Keys.All(r => r.KeyId != k.KeyId)))
Console.WriteLine($" − retirée : {removed.Name} (clé {removed.KeyId})");
if (request.Keys.Count == 0)
Warn("Aucune clé : les launchers en mode « require » n'accepteront plus aucun contenu.");
if (toCheck > 0)
Warn($"{toCheck} clé(s) à vérifier HORS DU SERVEUR : demandez à chaque personne de lire l'empreinte de son appareil " +
"(menu d'administration › Mes appareils) de vive voix ou par téléphone, et comparez. Une clé que vous ne " +
"reconnaissez pas peut venir d'un serveur compromis : abandonnez.");
if (!o.Has("--yes"))
{
Console.Write("Signer ce trousseau avec la clé racine ? (o/N) ");
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
if (answer is not ("o" or "oui"))
{
Warn("Abandon : rien n'a été signé.");
return 1;
}
}
using var rootKey = LoadPrivateKey(keyPath);
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
if (roots is not null && roots.All(k => !string.Equals(k.KeyId, rootKeyId, StringComparison.OrdinalIgnoreCase)))
throw new CryptographicException(
$"La clé {rootKeyId} n'est pas dans {Path.GetFileName(o.Get("--public-key"))} : les launchers refuseraient ce trousseau.");
if (File.Exists(output) && !o.Has("--force"))
throw new IOException($"{output} existe déjà (--force pour le remplacer).");
File.WriteAllText(output, Keyring.Serialize(Keyring.Sign(request, rootKey)));
Success($"Trousseau n° {request.Sequence} signé par la clé racine {rootKeyId} : {output}");
Console.WriteLine(" → publiez-le : menu d'administration › Signature › « Publier le trousseau signé… ».");
Console.WriteLine(" → gardez ce fichier dans ce dossier : il servira de référence pour le prochain trousseau.");
return 0;
}
/// <summary>Trousseau signé le plus récent (keyring-N.json, N inférieur à la demande) du dossier, sinon null.</summary>
internal static string? FindPreviousKeyring(string directory, long requestSequence, IReadOnlyList<(string KeyId, byte[] PublicKey)>? roots)
{
var candidates = new List<(long Sequence, string Path)>();
foreach (var path in Directory.EnumerateFiles(directory, "keyring-*.json"))
{
var name = Path.GetFileNameWithoutExtension(path)["keyring-".Length..];
if (!long.TryParse(name, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var number)) continue;
try
{
if (new FileInfo(path).Length > Keyring.MaxBytes || Keyring.Parse(File.ReadAllBytes(path)) is not { Signature: not null } keyring) continue;
if (keyring.Sequence >= requestSequence || keyring.Sequence != number) continue;
if (roots is not null && !Keyring.VerifyRoot(keyring, roots, out _)) continue;
candidates.Add((keyring.Sequence, path));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException)
{
// fichier illisible : ignoré
}
}
return candidates.OrderByDescending(c => c.Sequence).Select(c => c.Path).FirstOrDefault();
}
private static int Usage()
{
Console.WriteLine("""
@@ -395,6 +514,13 @@ public static class Program
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
Vérifie la signature (et l'exécutable).
sign-keyring --in <keyring-request.json> [--out <keyring-N.json>] [--key <launcher-signing.key>]
[--previous <keyring-M.json>] [--public-key <update-signing.pub>] [--yes] [--force]
Signe avec la clé racine le trousseau exporté par la page « Signature » du menu
d'administration (clés autorisées à signer versions et catalogue). Compare la demande
au trousseau signé précédent (par défaut : keyring-M.json le plus récent du dossier)
et signale les clés nouvelles ou aux autorisations élargies.
Phrase de passe : saisie au clavier ou variable d'environnement LAUNCHER_KEY_PASSPHRASE.
""");
return 1;
+795 -32
View File
@@ -1,45 +1,808 @@
# Launcher 1.2.1
# Launcher 2.0.0
LAUNCHER - NOTES DE MISE À JOUR
Mise à jour du 25 octobre 2026
Version majeure : le menu d'administration - Mise à jour du 1er novembre 2026
==================================================================
Cette version permet de garder le launcher discrètement près de l'horloge, pendant la partie ou à la place de le fermer.
Version majeure : le launcher s'administre désormais depuis le launcher lui-même. Informations des jeux, notes de version, publication d'une nouvelle version, gestion des administrateurs, journal d'audit et signature de tout ce que reçoivent les joueurs.
Le tout repose sur un nouveau serveur, la Launcher API, et sur une authentification par clé publique / clé privée : aucun mot de passe, aucun secret dans l'exécutable, aucun secret sur le serveur.
Pour les joueurs, rien ne change : le menu d'administration est invisible, ne contacte le serveur qu'une fois ouvert, et les nouveautés côté joueur (catalogue des jeux, signature du contenu) sont rétrocompatibles et désactivables.
------------------------------------------------------------------
ZONE DE NOTIFICATION
EN BREF
------------------------------------------------------------------
Le launcher peut maintenant se ranger dans la zone de notification
de Windows (icônes cachées, près de l'horloge) :
- NOUVEAU : icône du launcher près de l'horloge.
* clic gauche : le launcher réapparaît ;
* clic droit : menu « Ouvrir » / « Quitter ».
- NOUVEAU : Paramètres du launcher (roue crantée) › Lancement du
jeu : sous « Réduire le launcher pendant la partie », l'option
« Dans la zone de notification » le range près de l'horloge
plutôt que dans la barre des tâches. Il réapparaît à la fermeture
du jeu.
- NOUVEAU : bloc « Fermeture du launcher » : « La croix réduit le
launcher dans la zone de notification ». Le launcher reste actif :
les téléchargements et les mises à jour continuent. Pour le
quitter : clic droit sur son icône › Quitter.
Ces deux options sont désactivées par défaut : rien ne change si
vous ne les activez pas.
- NOUVEAU : menu d'administration intégré : accès discret, connexion
par clé, verrouillage automatique.
- NOUVEAU : clé d'administrateur en fichier chiffré ou dans la puce
TPM 2.0 du PC (non exportable).
- NOUVEAU : tableau de bord : compte, droits, session, serveur,
jeux, approbations en attente.
- NOUVEAU : page « Jeux » : nom affiché, accroche, description,
liens, thème, icône, fond, ordre… sans republier le launcher.
- NOUVEAU : page « Notes de version » : éditeur graphique avec un
aperçu identique au launcher.
- NOUVEAU : page « Publier une version » : envoi du jeu depuis le
launcher, avec reprise et vérification.
- NOUVEAU : pages « Mes appareils », « Administrateurs » et
« Journal d'audit ».
- NOUVEAU : page « Signature » et signature de bout en bout :
versions et catalogue signés, vérifiés par les launchers des
joueurs.
- NOUVEAU : Launcher API : serveur ASP.NET Core durci (systemd,
nginx), modules, audit, outil en ligne de commande.
- QUALITÉ : 232 tests automatisés (unitaires et de bout en bout,
serveur démarré en mémoire).
------------------------------------------------------------------
AUTRES AMÉLIORATIONS
OUVRIR LE MENU D'ADMINISTRATION
------------------------------------------------------------------
- « Quitter » pendant un téléchargement : le launcher réapparaît et
vous demande confirmation.
- L'icône du jeu sur le Bureau fonctionne aussi quand le launcher
est caché près de l'horloge : il réapparaît et lance le jeu.
- Le message « Le launcher est déjà ouvert » indique où le
retrouver s'il est caché près de l'horloge.
- À l'arrêt de Windows ou à la fermeture de session, le launcher se
ferme normalement, sans rien demander.
- NOUVEAU : Ctrl + Maj + Alt + A dans la fenêtre du launcher, ou 5
clics rapides (en moins de 3 secondes) sur le logo en haut à
gauche.
- L'accès est discret mais pas secret : seule la connexion par clé,
vérifiée par le serveur, protège l'administration. Masquer le
bouton n'est qu'un confort.
- Fenêtre dédiée au design moderne (coins arrondis, icônes, thème
sombre) :
* à gauche : l'administrateur connecté, les pages auxquelles il a
droit, le temps restant avant verrouillage et le bouton
« Verrouiller » ;
* à droite : la page choisie.
- Seules les pages autorisées par les droits renvoyés par le serveur
sont affichées : le serveur reste seul juge.
- Sans « apiUrl » dans launcher.json, le menu indique clairement
qu'il n'est relié à aucun serveur.
La sécurité ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
------------------------------------------------------------------
VERROUILLAGE AUTOMATIQUE
------------------------------------------------------------------
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 à l'écran de
connexion) dans les cas suivants :
- SÉCURITÉ : déclencheurs du verrouillage :
* inactivité : 10 minutes sans activité dans la fenêtre ;
* durée maximale : 8 heures de session ;
* Windows : session verrouillée (Win + L, mise en veille) ;
* manuel : Ctrl + L ou bouton « Verrouiller » ;
* serveur : session refusée (révocation, redémarrage du service).
- Tant que l'administrateur est actif, la session est prolongée
automatiquement sur le serveur toutes les 4 minutes.
- Fermer la fenêtre ou le launcher efface la clé, puis ferme la
session sur le serveur.
- La phrase de passe n'est jamais conservée : le champ est vidé dès
la tentative de connexion.
- Pendant l'envoi d'une version, le verrouillage pour inactivité est
suspendu (le verrouillage manuel, celui de Windows et la durée
maximale restent appliqués).
------------------------------------------------------------------
CLÉS D'ADMINISTRATEUR
------------------------------------------------------------------
- NOUVEAU : clé rangée dans un fichier chiffré :
* algorithme ECDSA P-256 / SHA-256 ;
* phrase de passe de 12 caractères minimum, chiffrement AES-256,
PBKDF2-SHA256 avec 600 000 itérations ;
* copiable et sauvegardable ;
* fichiers sur le disque : la clé chiffrée (.key) et sa clé
publique (.pub).
- NOUVEAU : clé rangée dans la puce de sécurité TPM 2.0 du PC :
* clé ECDSA P-256 générée dans la puce, qui ne peut pas en sortir
(non exportable) ;
* code PIN de 6 caractères minimum, vérifié par la puce, avec
blocage anti-essais ;
* fichiers sur le disque : une référence .tpmkey (sans aucun
secret) et la clé publique .pub ;
* disparaît avec la carte mère, une réinitialisation du TPM ou une
réinstallation de Windows.
- Dossier proposé : %USERPROFILE%\.launcher-keys. Une clé n'écrase
jamais un fichier existant.
- Le choix TPM n'est proposé qu'après une vérification réelle de la
puce (création, signature et suppression d'une clé de test).
- SÉCURITÉ : à la connexion TPM, la clé publique de la puce est
comparée à celle du fichier de référence avant tout essai du code
PIN. Le fichier .tpmkey ne peut désigner que des clés
« GameLauncher.Admin.* ».
- Après un code PIN 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).
- NOUVEAU : bouton « Supprimer cette clé de la puce » sur l'écran de
connexion (clé TPM uniquement, après confirmation).
- CONSEIL : avec une clé TPM, gardez aussi une clé fichier de
secours.
- SÉCURITÉ : clés refusées :
* la clé de signature du launcher (launcher-signing*.key, copies
comprises) n'est jamais proposée ni acceptée ;
* toute clé dont l'identifiant ou la clé publique est celle de la
clé racine intégrée au launcher est refusée, quel que soit le
nom du fichier.
------------------------------------------------------------------
CONNEXION ET REQUÊTES SIGNÉES
------------------------------------------------------------------
- NOUVEAU : connexion par défi signé :
* le launcher demande un défi au serveur (sans état : valable 60
secondes, nonce de 256 bits, scellé par HMAC, réponse identique
pour une clé connue ou inconnue) ;
* il signe un message de connexion contenant l'origine,
l'identifiant du serveur, le défi, le nonce et la clé ;
* le serveur vérifie le sceau, l'échéance, la signature, que la
clé et le compte sont actifs, et que le défi n'a jamais été
utilisé ;
* il renvoie un jeton de session de 256 bits (seule son empreinte
est conservée) et les droits.
- SÉCURITÉ : toute requête qui modifie quelque chose porte, en plus
du jeton, une signature de la clé de l'administrateur (méthode,
chemin, empreinte du contenu, horodatage, nonce).
* un jeton volé ne permet aucune modification ;
* le journal d'audit sait quelle clé a signé chaque action.
- NOUVEAU : « J'ai une invitation » : crée la clé sur ce PC (fichier
ou TPM), l'enregistre auprès du serveur avec le code d'invitation
et une preuve de possession, puis affiche l'empreinte à
communiquer à la personne qui approuve.
* empreinte lisible : 5 groupes de 4 caractères (80 bits) ;
* sans code : création d'une clé seule (premier propriétaire, à
déclarer sur le serveur).
------------------------------------------------------------------
TABLEAU DE BORD
------------------------------------------------------------------
- NOUVEAU : page d'accueil du menu :
* compte connecté : nom, initiales, rôle et liste des droits ;
* clé utilisée : nom de l'appareil et identifiant ;
* session : heure de fin au plus tard ;
* serveur : adresse, version, identifiant ;
* jeux déclarés dans launcher.json (nom, identifiant, serveur du
manifest) ;
* pour les propriétaires : appareils en attente d'approbation et
invitations actives ;
* raccourcis vers les pages « Administrateurs » et « Mes
appareils » ;
* bouton « Actualiser ».
------------------------------------------------------------------
PAGE « JEUX » : LE CATALOGUE DES JEUX
------------------------------------------------------------------
Permission « games.edit » : propriétaires, administrateurs et
éditeurs. Les informations d'affichage des jeux se modifient
désormais sans recompiler ni republier le launcher. Les jeux restent
déclarés dans launcher.json : le serveur ne peut ni ajouter ni
retirer un jeu.
- NOUVEAU : réglages du launcher : nom du launcher, jeu affiché au
premier lancement, ordre des jeux (boutons ↑ ↓).
- NOUVEAU : réglages de chaque jeu :
* nom affiché et accroche ;
* description (texte long : 2000 caractères, 40 lignes) ;
* liens : Discord, YouTube, forum… jusqu'à 8, en https ;
* site web ;
* thème (thèmes intégrés uniquement) ;
* icône et fond (images PNG ou JPEG du serveur du catalogue) ;
* fréquence des recherches de mise à jour (0, ou de 5 à 1440
minutes) ;
* jeu masqué.
- SÉCURITÉ : restent dans l'exécutable, jamais modifiables à
distance : identifiants du launcher et des jeux, éditeur, adresses
du manifest, de la mise à jour, de l'API, du catalogue et du
trousseau, dossiers de données supprimés à la désinstallation,
dossier d'installation par défaut, exécutable et arguments du jeu.
- Ergonomie :
* champ vide = valeur de launcher.json, rappelée sous le champ ;
* pastille « modifié » sur les champs changés ;
* contrôle complet avant l'envoi, avec les mêmes règles que le
serveur ;
* si quelqu'un a publié entre-temps, rien n'est écrasé : la page
propose de recharger ;
* bandeau explicatif si la publication n'est pas configurée sur le
serveur.
- SÉCURITÉ : règles appliquées : 48 Ko au plus, 50 jeux, noms de 80
caractères, accroche de 200, adresses https uniquement (500
caractères, sans identifiants), images .png ou .jpg du même
serveur que le catalogue, thème désigné par un identifiant (jamais
un chemin ni du XAML distant).
- NOUVEAU : le catalogue est signé automatiquement à chaque
publication.
------------------------------------------------------------------
PAGE « NOTES DE VERSION »
------------------------------------------------------------------
Permission « changelog.edit » : propriétaires, administrateurs et
éditeurs. Corrige les notes des versions déjà publiées, directement
dans le manifest.json du serveur.
- NOUVEAU : éditeur graphique :
* liste des versions publiées ;
* en-tête : titre, sous-titre, introduction ;
* sections, puis éléments : modification ou commentaire,
étiquette, sous-points, note ;
* boutons ↑ ↓ pour réordonner sections et éléments.
- NOUVEAU : aperçu en direct avec exactement la présentation du
launcher (mêmes modèles, mêmes limites).
- Une ancienne version au format « liste à plat » est présentée en
sections et enregistrée au format enrichi ; la liste à plat est
régénérée pour les anciens launchers.
- « Enregistrer » ne change que les notes : fichiers, archive,
version et exécutable restent identiques.
- Si le manifest a changé entre-temps (nouvelle version publiée,
autre administrateur), rien n'est écrasé et la page propose de
recharger.
- Les limites sont vérifiées avant l'envoi, sans rien tronquer en
silence.
- NOUVEAU : ManifestBuilder, option « --remote-manifest <adresse> »
(ajoutée à tools\publish-example.bat) : l'historique des notes est
repris du manifest en ligne, seule la nouvelle version vient de
notes.txt.
* les corrections faites depuis le menu d'administration ne sont
plus perdues à la publication suivante ;
* erreur 404 : première publication (historique local) ; toute
autre erreur arrête la publication ;
* republier une version sans « --notes » conserve ses notes
existantes.
------------------------------------------------------------------
PAGE « PUBLIER UNE VERSION »
------------------------------------------------------------------
Permission « releases.publish » : propriétaires et administrateurs.
Publie une nouvelle version du jeu depuis le launcher, à partir du
dossier produit par le .bat (ManifestBuilder, qui reste l'outil de
génération).
- NOUVEAU : déroulement :
* choix du manifest.json produit par le .bat ;
* contrôle local du dossier : manifest valide, archive et fichiers
présents avec la bonne taille, chemins sûrs (mêmes règles que le
serveur) ;
* rappel de la version en ligne, avec un avertissement si le
numéro est identique ou inférieur ;
* envoi du manifest, contrôlé par le serveur, qui répond avec la
liste des fichiers absents de son dossier objects/ ;
* envoi de ces fichiers uniquement : petits fichiers par lots (des
centaines par requête), gros fichiers par morceaux ;
* envoi de l'archive ZIP par morceaux de 4 Mo ;
* mise en ligne : manifest.json en dernier, remplacé d'un seul
coup ; l'historique des notes du serveur est conservé.
- NOUVEAU : reprise : « Interrompre » garde ce qui a été reçu,
« Publier » reprend à la position exacte.
- Coupures réseau, délais, serveur occupé : l'état est relu sur le
serveur et l'envoi continue automatiquement après une courte
pause, affichée sur la page.
- SÉCURITÉ : chaque fichier est vérifié par son empreinte SHA-256
des deux côtés ; un fichier modifié depuis le .bat est refusé.
- L'espace disque est vérifié par le serveur avant d'accepter les
fichiers.
- Si l'écriture du manifest échoue, l'ancienne archive est remise en
place.
- Si les fichiers en ligne ont changé pendant l'envoi (autre
publication, WinSCP), la mise en ligne est refusée ; une simple
correction des notes reste permise.
- Réponse perdue : le serveur garde le résultat 24 heures, un nouvel
essai le retrouve.
- Une publication à la fois par jeu ; celle d'un autre
administrateur n'est remplaçable qu'après 30 minutes sans envoi ;
abandon automatique après 48 heures.
- NOUVEAU : la version est signée juste avant la mise en ligne. La
reprise n'est pas affectée, et la signature est refaite si la mise
en ligne est retentée plus de 5 minutes après.
- NOUVEAU : « Signer la version en ligne » : pour une version déjà
en ligne (envoyée avec WinSCP ou avant la 2.0.0), depuis son
dossier. Accepté uniquement si les fichiers en ligne sont
identiques à ceux du dossier.
------------------------------------------------------------------
PAGE « MES APPAREILS »
------------------------------------------------------------------
Pour tout administrateur.
- NOUVEAU : liste de vos PC (clés) avec leur empreinte, leur date
d'ajout et leur dernière connexion.
- SÉCURITÉ : l'empreinte de l'appareil en cours est calculée sur
votre PC, pas annoncée par le serveur ; une différence avec celle
du serveur est signalée.
- NOUVEAU : « Ajouter un appareil » :
* crée une invitation « nouvel appareil » (code affiché une seule
fois) ;
* sur le nouveau PC : « J'ai une invitation » ;
* de retour ici : l'appareil en attente s'approuve en saisissant
l'empreinte affichée sur le nouveau PC.
- SÉCURITÉ : personne d'autre, pas même un propriétaire, ne peut
ajouter un appareil à votre compte.
- Suppression de vos propres invitations « nouvel appareil ». La
révocation d'un appareil perdu est réservée aux propriétaires.
------------------------------------------------------------------
PAGE « ADMINISTRATEURS »
------------------------------------------------------------------
Pour les propriétaires (permission « admins.manage »).
- Comptes en attente d'abord, puis actifs (vous en premier) ; les
comptes révoqués sur demande.
- NOUVEAU : inviter : nom et rôle, puis code d'invitation à usage
unique (160 bits, valable 24 heures).
* affiché une seule fois et copiable ;
* « J'ai transmis le code » l'efface de l'écran ;
* jamais enregistré par le launcher.
- NOUVEAU : approuver un premier appareil en saisissant l'empreinte
dictée par la personne. Format contrôlé sur le PC, comparaison par
le serveur ; l'empreinte d'une clé en attente n'est jamais
affichée : l'approbation prouve la vérification.
- NOUVEAU : changer un rôle, avec une confirmation renforcée pour
« Propriétaire ».
- NOUVEAU : révoquer un appareil ou un compte (effet immédiat) ;
supprimer une invitation.
- SÉCURITÉ : garde-fous : impossible de changer son propre rôle, de
se révoquer, de révoquer la clé de la session en cours, ou de
retirer le dernier propriétaire actif.
- Rôles et droits :
* propriétaire : gestion des administrateurs et de la signature,
journal d'audit, jeux, notes de version, publication ;
* administrateur : journal d'audit, jeux, notes de version,
publication ;
* éditeur : jeux et notes de version.
------------------------------------------------------------------
PAGE « JOURNAL D'AUDIT »
------------------------------------------------------------------
Pour les propriétaires et les administrateurs (permission
« audit.read »).
- NOUVEAU : connexions, invitations, approbations, publications,
modifications et refus, les plus récents d'abord.
- Chargement par pages de 200 entrées, 5 000 au plus en mémoire.
- NOUVEAU : filtres par catégorie, par résultat et par texte libre
(nom, adresse IP, clé…).
- NOUVEAU : export CSV des entrées filtrées, protégé contre
l'injection de formules dans les tableurs.
- Actions inscrites au journal :
* connexion : connexion, déconnexion, requête signée refusée ;
* administrateurs : invitation créée ou supprimée, enregistrement
d'une clé, approbation, révocation d'une clé ou d'un compte,
changement de rôle ;
* ligne de commande du serveur : ajout d'un propriétaire ou d'une
clé, révocations ;
* contenu : publication du catalogue, modification des notes de
version ;
* publication : début, mise en ligne, abandon, signature d'une
version en ligne ;
* signature : publication du trousseau (clés ajoutées et
retirées).
- SÉCURITÉ : les échecs sans signature valide sont détaillés 10 fois
par minute au plus, puis résumés (protection contre l'inondation
du journal) ; les échecs avec une signature valide (clé révoquée,
rejeu…) sont toujours détaillés.
------------------------------------------------------------------
PAGE « SIGNATURE » : SIGNATURE DE BOUT EN BOUT
------------------------------------------------------------------
Pourquoi ? Sans signature, quelqu'un qui prendrait le contrôle du
serveur pourrait distribuer un faux jeu. Désormais, chaque version
et le catalogue portent la signature de l'administrateur qui les a
publiés, et les launchers ne font confiance qu'aux clés d'un
trousseau signé hors ligne. Le serveur ne détient aucune clé privée.
- Chaîne de confiance :
* la clé racine (hors ligne, celle des mises à jour du launcher)
signe le trousseau keyring.json ;
* le trousseau autorise les clés des administrateurs ;
* ces clés signent le manifest.json de chaque version et le
catalog.json ;
* le launcher des joueurs vérifie le tout.
- NOUVEAU : page « Signature » (propriétaires) :
* trousseau publié : numéro, date, nombre de clés, signature
racine vérifiée par ce launcher, adresse à mettre dans
launcher.json ;
* trousseau proposé, calculé à partir des appareils actifs des
comptes autorisés à publier, avec les différences (à ajouter, à
retirer, autorisations modifiées) ;
* autorisation « versions du jeu » si le rôle peut publier une
version, « catalogue » s'il peut modifier les jeux ;
* état de chaque contenu en ligne (versions, catalogue) : signé ou
non, par qui, valide ou non pour les launchers ;
* alerte si un contenu deviendrait invalide avec le trousseau
proposé (à re-signer d'abord) ;
* « Exporter la demande… » : fichier à signer hors ligne (possible
aussi pour remplacer un trousseau refusé) ;
* « Publier le trousseau signé… » : signature racine et numéro
vérifiés avant l'envoi ; le serveur exige un numéro strictement
croissant.
- SÉCURITÉ : les noms d'appareils sont nettoyés (caractères
invisibles, inversion du sens d'écriture) et la page rappelle
qu'ils sont publics.
- NOUVEAU : outil tools\sign-keyring.bat : signature hors ligne du
trousseau.
* glissez la demande sur le .bat : elle est comparée au trousseau
signé précédent (keyring-N.json du même dossier) ;
* chaque nouvelle clé ou autorisation élargie est signalée en
couleur ;
* rappel de vérifier ces empreintes hors du serveur (de vive voix,
avec la page « Mes appareils » de chaque personne) ;
* confirmation, phrase de passe de la clé racine, puis écriture de
keyring-N.json (à garder : il sert de référence la fois
suivante) ;
* une clé racine absente de update-signing.pub est refusée.
- Ce qui est signé :
* manifest.json : jeu, version, exécutable, arguments, archive
(adresse, taille, empreinte), dossier des fichiers, liste des
fichiers (chemin, taille, empreinte) ;
* non couvert dans le manifest : notes de version et date
(corrigeables en ligne, jamais exécutées) ;
* catalog.json : tout le contenu affiché (textes, liens, thèmes,
images référencées, ordre) ;
* non couvert dans le catalogue : révision, date et contenu des
images.
- La signature est intégrée au fichier JSON : les launchers 1.x
l'ignorent et continuent de fonctionner.
------------------------------------------------------------------
SIGNATURE : VÉRIFICATION PAR LES LAUNCHERS DES JOUEURS
------------------------------------------------------------------
- NOUVEAU : trois modes (« contentSigning ») :
* off : rien n'est vérifié (comportement de la 1.x, par défaut
sans adresse de trousseau) ;
* warn : signatures vérifiées, problèmes inscrits au journal,
contenu utilisé (période de mise en place) ;
* require : contenu non signé, mal signé ou plus ancien que le
dernier vu refusé.
- SÉCURITÉ : en mode « require » :
* manifest refusé : message « Mise à jour bloquée par sécurité »,
aucune installation ni mise à jour, le jeu installé reste
jouable ;
* catalogue refusé : ignoré, le cache précédent est conservé ;
* anti-retour en arrière : un contenu signé plus tôt que le
dernier accepté est refusé ;
* le catalogue en cache est revérifié à chaque démarrage, hors
ligne ;
* un catalogue dont deux jeux ne diffèrent que par la casse est
refusé, même signé.
- Trousseau côté joueur :
* téléchargé au démarrage, vérifié avec la clé racine, mis en
cache ;
* refusé s'il est plus ancien que le dernier vu, différent sous le
même numéro, ou plus ancien que « keyringMinSequence » ;
* hors ligne ou en cas d'erreur : le dernier trousseau vu est
utilisé, aucune erreur ne fait planter le launcher ;
* contenu signé par une clé inconnue : nouveau téléchargement du
trousseau, au plus une fois toutes les 5 minutes ;
* date de signature retenue plafonnée à « maintenant + 1 jour » :
une horloge déréglée ne bloque pas les versions suivantes.
------------------------------------------------------------------
NOUVEAUTÉS POUR LES JOUEURS
------------------------------------------------------------------
- NOUVEAU : fiche du jeu enrichie : description et boutons de liens
(site, Discord, vidéo, forum… avec icônes).
- NOUVEAU : nom affiché, accroche, thème, icône, fond et ordre des
jeux mis à jour depuis le serveur, sans nouvelle version du
launcher.
- AMÉLIORÉ : démarrage sans attente : le catalogue en cache est
appliqué immédiatement ; la version à jour est téléchargée en
parallèle et appliquée si elle arrive en 2,5 secondes (sinon au
démarrage suivant).
- Hors ligne : dernier catalogue en cache, sinon les valeurs de
launcher.json.
- SÉCURITÉ : images contrôlées (vrai PNG ou JPEG, 4 Mo au plus),
téléchargées uniquement depuis le serveur du catalogue, mises en
cache ; fonds réduits à 3840 pixels.
- Le nom affiché ne change ni le raccourci, ni le registre, ni les
dossiers.
- Un jeu masqué reste affiché s'il est installé ou demandé par un
raccourci ; au moins un jeu est toujours affiché.
- NOUVEAU : vérification des signatures, activable progressivement
(« warn » puis « require »).
------------------------------------------------------------------
LAUNCHER API (SERVEUR)
------------------------------------------------------------------
Nouveau serveur ASP.NET Core (.NET 8), publié pour Linux x64 en
version autonome, derrière nginx.
- NOUVEAU : modules du serveur :
* System : informations et état de santé ;
* Auth : défi, connexion, session, déconnexion, enregistrement
d'une clé ;
* Admins : administrateurs, clés, invitations, rôles, révocations,
« mon compte » ;
* Audit : journal d'audit ;
* Catalog : lecture et publication de catalog.json ;
* Changelog : notes de version dans le manifest publié ;
* Publishing : envoi par morceaux, reprise, mise en ligne ;
* Signing : trousseau, état et signature des contenus.
- Routes de l'API (/api/v1) et droits :
* info, health : publiques ; auth/challenge, auth/login :
publiques, limitées ; enrollment : code d'invitation ;
* auth/logout, auth/me, admins/me : administrateur connecté ;
* admins, admins/invitations (liste) : admins.manage ; création et
suppression d'invitations : connecté, droits revérifiés ;
* approbation d'une clé : propriétaire (premier appareil) ou
titulaire du compte (nouvel appareil) ;
* révocation d'une clé ou d'un compte, changement de rôle :
admins.manage ;
* audit : audit.read ; catalog : lecture pour tous, publication
games.edit ;
* games/{jeu}/changelog : lecture pour tous, modification
changelog.edit ;
* games/{jeu}/publications et publications/{id}/… (manifest,
archive, objets, mise en ligne) : releases.publish ;
* signing, keyring : admins.manage ; games/{jeu}/signature :
releases.publish.
Toutes les routes qui modifient quelque chose exigent une requête
signée.
- Stockage :
* aucune base de données : fichiers JSON écrits d'un seul coup
(fichier temporaire puis renommage), verrou entre processus,
sauvegarde de la version précédente ;
* données privées dans /var/lib/launcher-api (droits 700), en
dehors du dossier SFTP ;
* uniquement des clés publiques et des empreintes de codes
d'invitation : une fuite complète des données ne permet pas de
se connecter ;
* historiques : 20 dernières versions du catalogue, 20 derniers
manifests modifiés, trousseaux successifs ;
* fichiers publics (catalogue, trousseau) écrits lisibles par
nginx (0644), malgré le masque restrictif du service.
- Limites :
* requêtes générales : 300 par minute et par adresse IP ;
* connexion : 20 par minute et par adresse IP ;
* morceaux de publication : 1500 par minute et par adresse IP
(limite dédiée) ;
* taille des requêtes : 64 Ko (8 Mo pour les routes d'envoi) ;
* sessions : 20 minutes d'inactivité, 8 heures au maximum, 5 par
administrateur ;
* horloge des requêtes signées : 2 minutes d'écart au plus ;
* date des signatures de contenu : 10 minutes d'écart au plus avec
l'heure du serveur.
Chaque refus 429 annonce son délai (Retry-After). Les adresses
IPv6 sont regroupées par /64.
- NOUVEAU : récupération en ligne de commande sur le serveur
(GameLauncher.Server, compte deploy) :
* add-owner : créer le premier propriétaire ;
* add-key : restaurer l'accès d'un compte qui n'a plus d'appareil
;
* list : lister les administrateurs ;
* revoke-key, revoke-admin : révoquer une clé ou un compte ;
* server-id : identifiant du serveur.
Une révocation en ligne de commande prend effet immédiatement, y
compris sur les sessions ouvertes.
------------------------------------------------------------------
SÉCURITÉ
------------------------------------------------------------------
Principe fondamental : le launcher est distribué à tous les joueurs,
tout ce qu'il contient est public. Le serveur est la seule barrière
de sécurité, et le serveur lui-même ne peut pas distribuer de
contenu sans la signature d'un administrateur.
- SÉCURITÉ : menaces et défenses :
* vol de mot de passe, hameçonnage : aucun mot de passe, clé
privée sur le PC (fichier chiffré ou puce TPM) ;
* force brute, saturation : limites par adresse IP (nginx et
serveur), défis sans état, sessions en nombre limité ;
* énumération des clés : défi identique pour une clé connue ou
inconnue, erreurs génériques ;
* rejeu : défi à usage unique (60 secondes), nonce et horodatage
des requêtes signées ;
* vol du jeton de session : inutile pour modifier (requêtes
signées), courte durée, lié à la clé ;
* fuite des données du serveur : uniquement des clés publiques et
des empreintes ;
* serveur compromis qui distribue un faux jeu : signature de bout
en bout, clé racine hors ligne ;
* serveur compromis qui ressert une ancienne version : anti-retour
en arrière (date de signature, numéro du trousseau) ;
* serveur compromis qui glisse sa clé dans le trousseau :
comparaison au trousseau précédent par sign-keyring, empreintes
vérifiées hors du serveur ;
* clé d'administrateur compromise : révocation, nouveau trousseau,
« keyringMinSequence » ;
* usurpation par un autre administrateur : un nouvel appareil
n'est approuvé que par le titulaire du compte ;
* erreur de manipulation : impossible de se révoquer, de changer
son propre rôle, de retirer le dernier propriétaire ;
* launcher.json modifié à côté de l'exe : les adresses de
confiance et les réglages de signature ne sont lus que dans
l'exe ;
* thème ou contenu distant malveillant : thèmes intégrés
uniquement, valeurs bornées, images contrôlées, même serveur ;
* export CSV piégé : toutes les cellules entre guillemets,
formules neutralisées ;
* en-têtes falsifiés : acceptés uniquement depuis la machine
elle-même, HTTPS exigé ;
* compromission du compte SFTP : données de l'API en dehors du
dossier SFTP.
------------------------------------------------------------------
DÉPLOIEMENT SUR LE SERVEUR
------------------------------------------------------------------
Guide complet pas à pas : server/deploy/LISEZMOI.md.
- NOUVEAU : script installer-api.sh : installation et mise à jour
vérifiées par empreinte SHA-256, programmes appartenant à root (le
service ne peut pas les modifier), réglages conservés, retour
automatique à la version précédente si le démarrage échoue.
- SÉCURITÉ : service systemd durci : compte deploy, masque 0077,
aucun privilège supplémentaire, système en lecture seule, dossiers
personnels et périphériques masqués, aucun bit spécial, réseau
limité à la machine elle-même, mémoire limitée à 512 Mo.
- Écriture autorisée uniquement dans les dossiers nécessaires, par
des compléments systemd séparés : catalogue et trousseau d'une
part, dossier du jeu d'autre part.
- nginx : API et limites, catalog.json et keyring.json sans cache,
routes d'envoi des publications (8 Mo, zone de limite dédiée).
- Dossier objects/ et ses 256 sous-dossiers créés à l'avance en 2750
(groupe www-data transmis).
- Étapes du guide : 3 bis (catalogue), 3 ter (notes de version), 3
quater (publication), 3 quinquies (signature), 4 (clé
d'administrateur), 8 (mise à jour du serveur).
------------------------------------------------------------------
OUTILS ET CONFIGURATION
------------------------------------------------------------------
- NOUVEAU : outil tools\publish-server.bat : publie le serveur
(Linux x64 autonome) avec son empreinte SHA-256.
- NOUVEAU : outil tools\admin-key.bat (AdminKeyTool) :
administration en ligne de commande :
* create, fingerprint : créer une clé, afficher son empreinte ;
* login-test, enroll : tester la connexion, enregistrer une clé
avec une invitation ;
* admins, invite, approve, role : lister, inviter, approuver,
changer un rôle ;
* revoke-key, revoke-admin, audit : révoquer, lire le journal.
- NOUVEAU : outil tools\sign-keyring.bat (LauncherPublisher
« sign-keyring ») : signe le trousseau avec la clé racine, hors
ligne.
- AMÉLIORÉ : outil tools\publish-example.bat (ManifestBuilder) :
option « --remote-manifest ».
- NOUVEAU : nouvelles clés de launcher.json, toutes facultatives (en
Release, lues uniquement dans le launcher.json intégré à l'exe) :
* apiUrl : adresse de la Launcher API (sans elle : pas de menu
d'administration) ;
* catalogUrl : catalogue des jeux ;
* keyringUrl : trousseau des clés autorisées ;
* contentSigning : « off », « warn » ou « require » (par défaut
« warn » si keyringUrl est présente, sinon « off ») ;
* keyringMinSequence : numéro minimal du trousseau accepté, à
relever après le retrait d'une clé compromise ;
* par jeu, nouvelles valeurs par défaut possibles : description,
links, icon, background, hidden.
------------------------------------------------------------------
CORRECTIONS ET STABILISATION
------------------------------------------------------------------
Corrigé pendant le développement de la 2.0.0, avant sa sortie :
- CORRIGÉ : publication : erreur « Le thread appelant ne peut pas
accéder à cet objet… » au clic sur « Publier ». L'avancement et
les boutons sont désormais toujours mis à jour sur le fil de
l'interface.
- CORRIGÉ : publication : le bandeau « L'envoi de la version … a été
interrompu » s'affichait pendant un envoi normal. Il n'apparaît
plus qu'une fois l'envoi réellement arrêté.
- CORRIGÉ : publication : pauses de plusieurs dizaines de secondes
pendant l'envoi. Limite de requêtes dédiée aux envois, délai
Retry-After respecté, pauses courtes (1 seconde doublée, 30
secondes au plus), zone nginx dédiée.
- CORRIGÉ : publication : échec de la mise en ligne (« pas les
droits ou plus de place ») sous systemd. Les sous-dossiers des
fichiers sont créés sans demander de bit spécial, et le guide les
fait créer à l'avance.
- CORRIGÉ : administration : erreurs du travail en arrière-plan
correctement remontées.
- CORRIGÉ : journal d'audit : le nom et le rôle de l'invitation
supprimée sont désormais inscrits.
- SÉCURITÉ : relecture de sécurité de la signature :
* identifiants du catalogue pris tels quels (sans mise en
minuscules) dans l'empreinte ;
* trousseau ou manifest incomplets refusés sans erreur ;
* noms d'appareils trompeurs refusés ;
* téléchargement du trousseau qui ne fait jamais planter le
launcher ;
* numéro minimal du trousseau, revérification du cache du
catalogue ;
* clé racine refusée à la connexion, empreinte locale sur « Mes
appareils ».
------------------------------------------------------------------
ARCHITECTURE TECHNIQUE
------------------------------------------------------------------
- NOUVEAU : projets de la solution :
* GameLauncher.Contracts : protocole partagé launcher / serveur
(routes, échanges, rôles, messages signés, catalogue, notes,
publication, signature) ;
* GameLauncher.ApiClient : client de l'API (défi signé, requêtes
signées, fichier de clé chiffré) ;
* GameLauncher.Modules : socle des modules, sans WPF (modules,
registre, pages, boîtes de dialogue, MVVM) ;
* GameLauncher.Modules.Admin : module « Administration » (services
et ViewModels testés sans interface) ;
* GameLauncher.Server : la Launcher API ;
* AdminKeyTool : clé d'administrateur et administration en ligne
de commande ;
* GameLauncher.Tests : tests unitaires et de bout en bout.
- Socle modulaire : le menu d'administration est le premier module ;
les futures fonctionnalités (actualités, amis, succès, sauvegardes
dans le cloud…) s'y brancheront de la même façon, côté launcher et
côté serveur.
- Ajouter une page : une classe de page, son descripteur et sa vue ;
la fenêtre d'administration ne change pas.
- Aucun paquet NuGet supplémentaire en dehors du projet de tests.
- Documentation : docs/ADMIN-ARCHITECTURE.md (architecture,
sécurité, décisions), README.md (§8), server/deploy/LISEZMOI.md.
------------------------------------------------------------------
MISE À NIVEAU DEPUIS LA 1.X
------------------------------------------------------------------
Aucune action n'est requise pour les joueurs : sans les nouvelles
clés dans launcher.json, le launcher 2.0.0 se comporte comme la 1.x.
- IMPORTANT : activer l'administration :
* installer la Launcher API sur le serveur (guide, étapes 1 à 3)
et créer le premier propriétaire (add-owner) ;
* ajouter apiUrl à launcher.json, recompiler et publier le
launcher ;
* activer les pages une à une : catalogue (3 bis), notes de
version (3 ter), publication (3 quater) ;
* inviter les autres administrateurs depuis la page
« Administrateurs ».
- IMPORTANT : activer la signature (guide, étape 3 quinquies) :
* mettre à jour le serveur et ajouter le bloc keyring.json à nginx
;
* page « Signature » : exporter la demande, la signer avec
tools\sign-keyring.bat, publier le trousseau signé ;
* signer la version en ligne (page « Publier une version ») et
republier le catalogue (page « Jeux ») ;
* launcher.json : keyringUrl, contentSigning « warn » et
keyringMinSequence 1, publier le launcher, vérifier le journal ;
* puis contentSigning « require » ; en option, exiger la signature
côté serveur (LauncherApi:ContentSigning:Require).
------------------------------------------------------------------
LIMITES CONNUES
------------------------------------------------------------------
- LIMITE : le contenu des images du catalogue n'est pas signé (un
serveur compromis pourrait les remplacer, pas les textes ni les
liens).
- LIMITE : un launcher qui n'a encore jamais vu de trousseau accepte
tout trousseau signé par la racine dont le numéro atteint
« keyringMinSequence ».
- LIMITE : un appareil ajouté au trousseau pendant qu'un launcher
tourne est connu au plus tard 5 minutes après, ou au démarrage
suivant.
- LIMITE : retirer une clé du trousseau invalide le contenu qu'elle
a signé (la page « Signature » le signale : re-signer d'abord).
- LIMITE : clé TPM :
* non sauvegardable ;
* le serveur ne peut pas prouver qu'une clé est dans une puce
(protection pour l'administrateur, pas une règle du serveur) ;
* le blocage anti-essais est commun à toute la puce (il compte
aussi les codes PIN Windows Hello).
- LIMITE : la création d'une nouvelle version et de ses notes se
fait toujours avec le .bat (ManifestBuilder) ; le menu publie et
corrige.
- LIMITE : un redémarrage du service déconnecte tous les
administrateurs (sessions en mémoire).
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
+63 -57
View File
@@ -1,82 +1,88 @@
# Version 1.7
# Version 1.8
EXOVIVAL ZOMBIE REBORN - NOTES DE MISE A JOUR
Niveau 1 « L'école » - Mise à jour du 31 octobre 2026
Niveau 1 « L'école » - Mise à jour du 1er novembre 2026
==================================================================
Merci aux bêta-testeurs ! Cette version apporte la vraie visée au pistolet, de nouveaux zombies plus effrayants et une exploration moins assistée : à vous d'ouvrir l'œil.
Merci aux bêta-testeurs ! Cette version remplit l'école de zombies qui rôdent, leur donne de l'oreille, rééquilibre les armes et ajoute de petits soins à fabriquer soi-même. Elle corrige aussi des zombies qui restaient bloqués sans attaquer.
------------------------------------------------------------------
PISTOLET : VISÉE PAR LE VISEUR
ZOMBIES QUI RÔDENT
------------------------------------------------------------------
- NOUVEAU : clic droit maintenu pour viser. L'arme vient devant l'œil
et les organes de visée s'alignent au centre de l'écran, là où part
la balle.
* le guidon se place entre les deux points du cran de mire ;
* relâchez le clic droit pour revenir à la hanche.
- En visée, l'arme ne balance plus quand vous tournez la caméra : la
mire reste alignée pendant que vous suivez une cible.
- Le point blanc du centre de l'écran disparaît pendant la visée et
revient dès que vous relâchez.
- L'aide des commandes en haut à droite indique maintenant « Viser »
pour le clic droit (au lieu du contrôle du zoom).
- NOUVEAU : huit zombies rôdent dans l'école dès le début de la
partie.
* autour du car scolaire, sur le terrain de basket ;
* dans la cour de service et sur le toit bas ;
* au gymnase, dans le bureau d'EPS et à l'étage.
- Ils sont plus lents et frappent moins fort que les zombies qui vous
traquent pendant l'histoire : de quoi se défouler un peu. On peut
les distancer en marchant.
- NOUVEAU : trois apparences différentes, dont une nouvelle : le
prisonnier.
- NOUVEAU : alerte de groupe. Quand un zombie vous attaque, ceux du
même endroit qui sont à proximité l'entendent et accourent.
- Les deux zombies de l'histoire (celui qui se réveille au retour du
courant et celui de la bibliothèque) restent les plus dangereux.
Astuce : au gymnase, attaquer un zombie de face attire souvent les autres.
------------------------------------------------------------------
ZOMBIES
LES ZOMBIES VOUS ENTENDENT
------------------------------------------------------------------
- NOUVEAU : nouveaux modèles, plus détaillés, pour le zombie du
couloir et pour celui de la bibliothèque.
* leurs déplacements, leurs attaques et leur comportement ne
changent pas.
- NOUVEAU : tir à la tête. Une balle dans la tête fait deux fois plus
de dégâts, sur les deux zombies.
Astuce : visez la tête, vos munitions dureront plus longtemps.
- NOUVEAU : marcher ou courir fait du bruit. Un zombie vous entend,
même dans son dos, se retourne et vous attaque.
* courir s'entend de beaucoup plus loin que marcher ;
* un mur ou une porte fermée étouffe le bruit ;
* on n'entend rien d'un étage à l'autre.
- NOUVEAU : accroupi (touche Ctrl), vous êtes silencieux : vous pouvez
approcher un zombie par derrière sans qu'il vous remarque.
- Dès que vous le blessez, le zombie se retourne et fonce sur vous.
- Accroupi ou pas, un zombie qui vous voit en face vous attaque.
------------------------------------------------------------------
LA SILHOUETTE DU COULOIR
ARMES : DÉGÂTS RÉÉQUILIBRÉS
------------------------------------------------------------------
- NOUVEAU : la silhouette du couloir ouest change d'apparence.
- Le flash lumineux qui apparaissait à côté d'elle est supprimé : elle
se découpe maintenant sur un halo doux, sans source de lumière
visible.
- Chaque arme fait désormais toujours les mêmes dégâts (plus de tirage
au hasard), et viser la tête double les dégâts, à toutes les armes.
* hache : un seul coup à la tête suffit, trois coups au corps ;
* couteau de poche : deux coups à la tête, sept au corps ;
* pistolet : trois balles dans la tête, sept au corps.
Astuce : approchez accroupi dans le dos d'un zombie et frappez à la tête avec la hache.
------------------------------------------------------------------
OBJETS ET EXPLORATION
PETITS SOINS À LA RESIDENT EVIL
------------------------------------------------------------------
- NOUVEAU : un petit cercle blanc s'affiche sur les objets que vous
pouvez prendre quand vous vous en approchez (2,5 m environ).
* piles, munitions, trousses de secours, pétrole, batteries ;
* outils, armes et sacs ;
* contenu des caisses et objets lâchés depuis l'inventaire.
- Les objets clés de l'histoire n'ont plus ni cercle ni reflet
lumineux : à vous de les repérer.
- Les panneaux d'aide fixés aux murs ont été retirés :
* au-dessus de la hache, dans le hall d'entrée ;
* au-dessus des interrupteurs du gymnase ;
* sur l'armoire de distribution électrique ;
* sur le coffret électrique de la bibliothèque.
- NOUVEAU : herbes médicinales, qui soignent un peu.
* les plantes vertes de l'école semblent perdre leurs feuilles :
regardez à leur pied, et au pied des buissons dehors.
- NOUVEAU : huiles médicinales, qui soignent davantage. Quelques
flacons traînent dans l'école.
- NOUVEAU : mélangez une herbe et une huile pour obtenir une potion de
soin, bien plus efficace.
* inventaire (Tab) › herbe › Combiner › huile ;
* c'est le seul mélange possible.
- La trousse de secours reste le meilleur remède.
- Comme la trousse, un soin ne peut pas être utilisé quand vous avez
toute votre vie : il n'est pas gaspillé.
- NOUVEAU : une note de l'infirmière, quelque part dans l'école,
explique tout cela.
- Les herbes et les huiles affichent le petit cercle blanc des objets
à prendre quand on s'en approche.
------------------------------------------------------------------
GROUPE ÉLECTROGÈNE
CORRIGÉ DEPUIS LA VERSION 1.7
------------------------------------------------------------------
- L'étiquette du groupe électrogène est mise à jour avec le nouvel
éclairage du gymnase.
* à allumer : circuits ÉCOLE et GYMNASE ;
* à laisser à l'arrêt : CANTINE et ÉCLAIRAGE EXTÉRIEUR.
- La note du concierge et la fiche de la remise parlent maintenant du
circuit GYMNASE.
- CORRIGÉ : un zombie arrivé au contact du joueur pouvait rester
immobile, les bras croisés, sans plus jamais frapper.
* surtout dans les escaliers ;
* ou quand le joueur était sur un banc, une marche ou des débris ;
* ou quand plusieurs zombies l'entouraient.
Le zombie se tourne maintenant toujours vers le joueur et attaque normalement.
------------------------------------------------------------------
CORRIGÉ DEPUIS LA VERSION 1.6
------------------------------------------------------------------
- La visée du pistolet ne fait plus un simple zoom : voir « Pistolet :
visée par le viseur ».
Vos sauvegardes restent compatibles : les nouveaux modèles, la visée et les nouveaux textes s'appliquent aussi aux parties en cours.
Pour profiter de tous les changements (zombies rôdeurs, herbes, huiles et note de l'infirmière à leur place de départ), commencez une nouvelle partie.
+27
View File
@@ -0,0 +1,27 @@
@echo off
REM ---------------------------------------------------------------------------
REM Signe le TROUSSEAU (cles autorisees a signer versions et catalogue) avec la
REM cle RACINE du launcher (celle des mises a jour), HORS LIGNE.
REM
REM 1. Menu d'administration > Signature > "Exporter la demande..."
REM (keyring-request-N.json)
REM 2. Glissez ce fichier sur sign-keyring.bat (ou : sign-keyring.bat <fichier>)
REM -> comparaison au trousseau signe precedent (keyring-N.json du meme dossier)
REM -> verifiez HORS DU SERVEUR l'empreinte de chaque NOUVELLE CLE (de vive
REM voix avec son proprietaire : page "Mes appareils"), phrase de passe
REM -> keyring-N.json cree a cote de la demande (gardez-le : reference suivante)
REM 3. Menu d'administration > Signature > "Publier le trousseau signe..."
REM
REM Cle racine : %USERPROFILE%\.launcher-keys\launcher-signing.key (jamais sur le
REM serveur, jamais ouverte par le launcher).
REM ---------------------------------------------------------------------------
setlocal
set ROOT=%~dp0..
if "%~1"=="" (
echo Usage : sign-keyring.bat ^<keyring-request.json^>
pause
exit /b 1
)
dotnet run --project "%ROOT%\tools\LauncherPublisher\LauncherPublisher.csproj" -c Release -- sign-keyring --in "%~1" --public-key "%ROOT%\src\GameLauncher\Security\update-signing.pub"
pause