Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ae5cbe63 | ||
|
|
86593a43ee | ||
|
|
8a7902e5e8 | ||
|
|
50ef1025d8 | ||
|
|
bcae86f1a1 | ||
|
|
bcde2a0dac | ||
|
|
86fef875a6 | ||
|
|
1b397ec930 | ||
|
|
c89bd62aff | ||
|
|
f556848da4 | ||
|
|
1cf9e87e82 | ||
|
|
dc09c3ecef | ||
|
|
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
|
||||
|
||||
@@ -489,7 +489,8 @@ Chantier en cours, découpé en phases : architecture, sécurité et planning da
|
||||
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**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**.
|
||||
**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é,
|
||||
@@ -546,6 +547,15 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
à 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.
|
||||
@@ -569,4 +579,5 @@ 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 | 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/` |
|
||||
+71
-11
@@ -507,19 +507,79 @@ que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste s
|
||||
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, recommandée)
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
|
||||
|
||||
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 :
|
||||
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
|
||||
|
||||
* `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.
|
||||
**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`).
|
||||
|
||||
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
|
||||
**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
|
||||
|
||||
@@ -533,7 +593,7 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|
||||
| **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) | ✅ |
|
||||
| **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é par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
|
||||
| **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.
|
||||
|
||||
@@ -28,7 +28,8 @@ public static class ApiModules
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule(),
|
||||
new Modules.Publishing.PublishingModule()
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -132,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);
|
||||
@@ -163,6 +199,7 @@ public sealed class LauncherApiOptions
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
ValidateCatalog();
|
||||
ValidateKeyring();
|
||||
ValidateGames();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
@@ -209,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);
|
||||
|
||||
@@ -333,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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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;
|
||||
|
||||
@@ -81,14 +84,36 @@ public sealed class PublishingModule : IApiModule
|
||||
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, ILogger<PublishingModule> logger) =>
|
||||
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
|
||||
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
var (result, record) = store.Commit(id, context.Admin().Admin.Id);
|
||||
// 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]}");
|
||||
(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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
|
||||
@@ -109,7 +110,7 @@ public sealed class PublicationStore
|
||||
AdminName = adminName,
|
||||
CreatedAt = _time.GetUtcNow(),
|
||||
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
|
||||
PublishedContentSha256 = ContentSignature(currentRoot)
|
||||
PublishedContentSha256 = OnlineContentHash(currentRoot)
|
||||
};
|
||||
CreateStaging(record);
|
||||
Save(record);
|
||||
@@ -238,7 +239,10 @@ public sealed class PublicationStore
|
||||
/// 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>
|
||||
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId)
|
||||
/// <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);
|
||||
@@ -268,14 +272,21 @@ public sealed class PublicationStore
|
||||
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 (ContentSignature(currentRoot) != record.PublishedContentSha256)
|
||||
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);
|
||||
|
||||
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne).
|
||||
// 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.
|
||||
@@ -395,12 +406,13 @@ public sealed class PublicationStore
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>Empreinte du manifest en ligne SANS les notes (fichiers, archive, version…) ; vide s'il n'existe pas.</summary>
|
||||
private static string ContentSignature(JsonObject? root)
|
||||
/// <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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
"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" } ».
|
||||
|
||||
@@ -227,6 +227,69 @@ Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cl
|
||||
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 :
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,10 +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;
|
||||
|
||||
@@ -44,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
|
||||
@@ -174,9 +182,38 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
|
||||
|
||||
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
|
||||
public Task<PublicationResult> CommitPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)), null, signed: true, ct,
|
||||
timeout: UploadTimeout);
|
||||
/// <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));
|
||||
|
||||
@@ -52,6 +52,17 @@ public static class ApiRoutes
|
||||
|
||||
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}";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,18 +170,27 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
|
||||
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)
|
||||
{
|
||||
FingerprintText = key.Fingerprint ?? 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 = key.Fingerprint;
|
||||
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;
|
||||
@@ -209,7 +218,7 @@ public sealed class AdminKeyItem : ObservableObject
|
||||
public string FingerprintText { get; }
|
||||
public string AddedText { get; }
|
||||
public string LastUsedText { get; }
|
||||
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}";
|
||||
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}{_fingerprintWarning}";
|
||||
|
||||
public bool CanApprove { get; }
|
||||
|
||||
|
||||
@@ -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}).");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -23,6 +24,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
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;
|
||||
@@ -38,6 +40,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -167,6 +170,73 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
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));
|
||||
@@ -234,6 +304,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
"). 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)
|
||||
{
|
||||
@@ -284,6 +355,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
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)
|
||||
{
|
||||
@@ -310,6 +382,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
OnPropertyChanged(name);
|
||||
UpdateWarnings();
|
||||
RefreshCommands();
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
private void UpdateWarnings()
|
||||
@@ -363,6 +436,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
CancelCommand.RaiseCanExecuteChanged();
|
||||
AbandonCommand.RaiseCanExecuteChanged();
|
||||
SignOnlineCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
|
||||
@@ -3,12 +3,16 @@ 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) =>
|
||||
@@ -34,6 +38,9 @@ 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";
|
||||
|
||||
@@ -85,6 +92,8 @@ public static class ReleaseUploader
|
||||
started?.Report(state);
|
||||
|
||||
var committing = false;
|
||||
ContentSignature? signature = null;
|
||||
var signedLocally = DateTime.MinValue;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var before = run.Done;
|
||||
@@ -103,7 +112,15 @@ public static class ReleaseUploader
|
||||
}
|
||||
// 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).
|
||||
return await session.CommitPublicationAsync(state.PublicationId, CancellationToken.None).ConfigureAwait(false);
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +125,7 @@ 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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -18,6 +19,7 @@ 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;
|
||||
@@ -54,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);
|
||||
@@ -159,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).
|
||||
@@ -294,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.)
|
||||
@@ -371,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();
|
||||
}
|
||||
@@ -453,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.
|
||||
@@ -512,6 +569,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
new ChangelogAdminModule(),
|
||||
new PublishAdminModule(),
|
||||
new AccountsAdminModule(),
|
||||
new SigningAdminModule(),
|
||||
new AuditAdminModule()
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
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: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.
|
||||
@@ -1076,8 +1077,19 @@
|
||||
</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,12" />
|
||||
<DockPanel>
|
||||
<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"
|
||||
@@ -1089,6 +1101,17 @@
|
||||
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>
|
||||
@@ -1625,4 +1648,165 @@
|
||||
</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>
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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>());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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