Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ae5cbe63 | ||
|
|
86593a43ee | ||
|
|
8a7902e5e8 | ||
|
|
50ef1025d8 | ||
|
|
bcae86f1a1 | ||
|
|
bcde2a0dac | ||
|
|
86fef875a6 | ||
|
|
1b397ec930 | ||
|
|
c89bd62aff | ||
|
|
f556848da4 | ||
|
|
1cf9e87e82 | ||
|
|
dc09c3ecef | ||
|
|
cbcc0b4f44 | ||
|
|
d0db02d7f3 | ||
|
|
63c2e37dad | ||
|
|
78780c20f1 | ||
|
|
3b95317d4c | ||
|
|
bb165c4be0 | ||
|
|
d2cb48b901 | ||
|
|
e06ddacf04 | ||
|
|
2e4b779be5 | ||
|
|
10558a6e5f | ||
|
|
54941a1009 | ||
|
|
bd8b76015f | ||
|
|
0d3d76a68e | ||
|
|
8862047e36 | ||
|
|
25c87d672e | ||
|
|
d687414872 | ||
|
|
fb71661f17 | ||
|
|
19ccd3bf9a | ||
|
|
370443f3d4 | ||
|
|
213cb9a533 | ||
|
|
dc740e0452 | ||
|
|
72e979afd6 | ||
|
|
1d5f85a77c | ||
|
|
629fb600e9 | ||
|
|
9455277c25 | ||
|
|
f1032df74a | ||
|
|
a341bc641a | ||
|
|
9b544cbc03 | ||
|
|
9aa3876a0e |
No files matched your search
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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-<id> » 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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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%\<Publisher>\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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<T> 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." ] } ]
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
Reference in new issue
Block a user