Files
PS_Launcher/server/tools/SignManifest.php
j.foucher c371a79f93 v0.26.0 — Channels per-license + tag BÊTA sur versions
DEUX features liées :

1. CHANNELS : chaque license peut être attribuée à un manifest distinct
   (« channel »). Permet de servir des versions différentes selon le
   client. Le serveur lit ?channel=X et sert manifest/versions-{X}.json
   avec fallback transparent sur versions.json. NULL = default.

2. BÊTA : nouveau flag isBeta + betaNotes par version dans le manifest.
   Visible uniquement par les licenses avec can_see_betas=1. Affichage
   d'une pill orange « BÊTA » sur la row + tooltip avec les notes pour
   les testeurs. Les installations locales déjà présentes restent
   visibles même si l'accès BÊTA est retiré ensuite (on n'efface pas
   le disque du client).

DB :
  002_channel_betas.sql ajoute channel + can_see_betas sur licenses.
  Idempotent (ALTER TABLE IF NOT EXISTS), zero data migration.

Serveur PHP :
  - ValidateLicense.php signe channel + canSeeBetas dans la réponse
    (ordre des clés CRITIQUE pour matcher le canonical client).
  - Manifest.php : whitelist regex anti-traversal sur ?channel=, fallback
    silencieux sur versions.json si channel inconnu (évite leak de la
    liste de channels par probing).
  - SignManifest.php prend un channel optionnel → l'admin peut signer
    chaque manifest indépendamment.
  - admin/licenses.php : dropdown channel + checkbox bêta sur create,
    bouton détails repliable par-row pour edit.
  - admin/versions.php : channel switcher en tête, badge BÊTA sur chaque
    row, dialog repliable « Bêta » avec checkbox + notes des testeurs.

Client C# :
  - License.Channel + License.CanSeeBetas (dans le canonical signé).
  - VersionManifest.IsBeta + BetaNotes.
  - ManifestService prend un channelProvider via DI, lu depuis license
    cachée à chaque fetch (lazy, pas de circular dep).
  - MainViewModel.RebuildList filtre les versions IsBeta si !CanSeeBetas
    (mais conserve les installées locales — on ne retire pas l'accès
    rétroactivement à ce qui est déjà sur disque).
  - VersionRowViewModel : props IsBeta / BetaNotes / BetaTooltip.
  - MainWindow.xaml : pill orange à côté du n° version pour le featured
    et les rows compactes, tooltip dynamique avec les notes testeurs.

Backward compat signature :
  Anciennes licenses cachées (signées sans channel/canSeeBetas) sont
  toujours validées via un fallback canonical legacy dans VerifySignature.
  Sans ce fallback, le passage à v0.26 invaliderait toutes les caches
  hors-ligne et bloquerait les users en mobilité.

Migration côté admin : jouer 002_channel_betas.sql sur la base, déployer
les fichiers PHP, créer manifest/versions-{channel}.json pour les
nouveaux channels (l'admin versions.php propose un input « Créer/utiliser
un nouveau channel »). Les licenses existantes restent en channel=NULL
= default = comportement actuel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:38:00 +02:00

318 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace PSLauncher\Tools;
/**
* Logique de re-signature du manifest, sans dépendance shell.
* Utilisable depuis le CLI (tools/sign-manifest.php) et depuis le backoffice
* web (admin/versions.php → action 'sync'). Évite tout appel à exec().
*/
final class SignManifest
{
public string $manifestPath;
public string $buildsDir;
public ?string $configPath;
public string $hashCachePath;
/** @var string[] */
public array $log = [];
/**
* @param string $rootDir Racine PS_Launcher (parent de api/, manifest/, builds/).
* @param string|null $channel Channel à signer. NULL/'' = manifest default
* (versions.json). Sinon versions-{channel}.json. Whitelist [a-z0-9_-]{1,64}.
* Le hashcache est partagé entre tous les channels (les ZIPs peuvent être
* référencés par plusieurs manifests, économise les recalculs).
*/
public function __construct(string $rootDir, ?string $channel = null)
{
$manifestFile = ($channel !== null && $channel !== '' && preg_match('/^[a-z0-9_-]{1,64}$/', $channel))
? "versions-{$channel}.json"
: 'versions.json';
$this->manifestPath = $rootDir . '/manifest/' . $manifestFile;
$this->buildsDir = $rootDir . '/builds';
$this->configPath = $rootDir . '/api/config.php';
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
// { size, mtime, sha256 } ; on recalcule uniquement si size ou mtime ont changé.
// Vit hors du répertoire web servi (sécurité : un utilisateur n'a aucune raison
// de pouvoir lire le cache).
$this->hashCachePath = $rootDir . '/manifest/.hashcache.json';
}
private function out(string $line): void { $this->log[] = $line; }
/**
* Lit le cache des hashs précalculés. Retourne un dictionnaire
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
* @return array<string, array{size:int,mtime:int,sha256:string}>
*/
private function loadHashCache(): array
{
if (!is_file($this->hashCachePath)) return [];
$raw = @file_get_contents($this->hashCachePath);
if ($raw === false) return [];
$j = json_decode($raw, true);
return is_array($j) ? $j : [];
}
/** @param array<string, array{size:int,mtime:int,sha256:string}> $cache */
private function saveHashCache(array $cache): void
{
@file_put_contents($this->hashCachePath, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
/**
* Calcule (ou retourne depuis le cache) le SHA-256 d'un fichier.
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
* ce qui permet de garder le cache à jour sans intervention manuelle.
*
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
* @return array{sha256:string, fromCache:bool, durationMs:int}
*/
private function getOrComputeSha256(string $path, array &$cache): array
{
$size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path;
if (isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === $mtime
&& !empty($cache[$key]['sha256'])) {
return ['sha256' => $cache[$key]['sha256'], 'fromCache' => true, 'durationMs' => 0];
}
$start = microtime(true);
$sha = hash_file('sha256', $path);
$duration = (int)((microtime(true) - $start) * 1000);
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
return ['sha256' => $sha, 'fromCache' => false, 'durationMs' => $duration];
}
/**
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
*
* Stratégie de hash :
* - Cache disque {$buildsDir}/.hashcache.json indexé par chemin → (size, mtime, sha256)
* - On re-hash seulement si la taille ou mtime du ZIP a changé (= upload de nouveau build)
* - Si le manifest contient déjà un sha256 valide ET que size+mtime n'ont pas changé,
* on évite carrément l'appel à hash_file()
* - Si une version a `download.hashAlgorithm = "none"` ou `download.skipHash = true`
* dans le manifest, on ne calcule pas le hash (sha256 reste à null/empty)
* - $force = true pour forcer un recalcul intégral (utile en cas de doute)
*
* Sur un mutualisé OVH avec 5 versions × 14 Go : avant ~25 min, après ~quelques secondes
* pour les versions inchangées + ~1 min par nouvelle version uploadée.
*
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
* versions[] (les autres restent inchangées). Le scope 'launcher'
* est ignoré dans ce cas.
* @return array{ok:bool, log:string[]}
*/
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array
{
if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}");
return ['ok' => false, 'log' => $this->log];
}
$manifest = json_decode(file_get_contents($this->manifestPath), true);
if (!is_array($manifest)) {
$this->out("Manifest illisible (JSON invalide)");
return ['ok' => false, 'log' => $this->log];
}
$doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher');
if ($onlyVersion !== null) {
// Mode "hash une seule version" : on ne touche pas au launcher,
// et on ne hash que la version demandée dans la section versions[].
$doLauncher = false;
$doVersions = true;
}
// Charge le cache des hashs (indexé par chemin réel du ZIP)
$cache = $this->loadHashCache();
$cacheChanged = false;
$hashedVersions = [];
if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?';
if ($onlyVersion !== null && $version !== $onlyVersion) {
continue; // skip silently les autres versions
}
$url = $v['download']['url'] ?? '';
if ($url === '') {
$this->out(" [skip] $version : pas d'URL dans le manifest");
continue;
}
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
$zip = "{$this->buildsDir}/$filename";
if (!is_file($zip)) {
$candidates = glob("{$this->buildsDir}/*{$version}*.zip", GLOB_NOSORT) ?: [];
$candidates = array_values(array_filter($candidates, 'is_file'));
if (count($candidates) === 1) {
$zip = $candidates[0];
$this->out(" [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place");
} else {
$this->out(" [skip] $version : ZIP introuvable pour $url");
if (count($candidates) > 1) {
$this->out(" Plusieurs candidats : " . implode(', ', array_map('basename', $candidates)));
}
continue;
}
}
$size = filesize($zip) ?: 0;
$v['download']['sizeBytes'] = $size;
// Hash skipping : per-version flag dans le manifest. Si "none" ou skipHash=true,
// on neutralise le sha256 et la vérif côté client est passée. Utile pour les builds
// internes ou les très gros ZIPs où on accepte le compromis perf/sécurité (la
// signature Ed25519 du manifest reste). À utiliser avec parcimonie.
$algo = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256'));
$skipFlag = !empty($v['download']['skipHash']);
if ($algo === 'none' || $skipFlag) {
$v['download']['sha256'] = '';
$v['download']['hashAlgorithm'] = 'none';
$this->out(" [skip-hash] $version : " . basename($zip) . " ($size octets) — hash non calculé (hashAlgorithm=none)");
$hashedVersions[] = $version;
continue;
}
// Cache lookup (sauf si --force)
if (!$force) {
$key = realpath($zip) ?: $zip;
$existingSha = (string)($v['download']['sha256'] ?? '');
$hasValidSha = $existingSha !== ''
&& !str_starts_with($existingSha, 'REPLACE');
if ($hasValidSha
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === (filemtime($zip) ?: 0)
&& ($cache[$key]['sha256'] ?? '') === $existingSha) {
$this->out(" [cache] $version : " . basename($zip) . " ($size octets) — sha256 inchangé, hash cache hit");
$hashedVersions[] = $version;
continue;
}
}
$r = $this->getOrComputeSha256($zip, $cache);
$cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
$v['download']['sha256'] = $r['sha256'];
$hashedVersions[] = $version;
}
unset($v);
// Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
// dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
if ($doLauncher && isset($manifest['launcher']) && is_array($manifest['launcher'])) {
$launcher = &$manifest['launcher'];
$lver = $launcher['version'] ?? '';
$lurl = $launcher['download']['url'] ?? '';
if ($lver !== '' && $lurl !== '') {
$lfile = basename(parse_url($lurl, PHP_URL_PATH) ?: '');
$lpath = "{$this->buildsDir}/launcher/{$lfile}";
if (!is_file($lpath)) {
// Fallback : tolérant sur le nom (PSLauncher-X.Y.Z.exe, etc.)
$candidates = glob("{$this->buildsDir}/launcher/*{$lver}*.exe", GLOB_NOSORT) ?: [];
if (count($candidates) === 1) $lpath = $candidates[0];
}
if (is_file($lpath)) {
$lsize = filesize($lpath) ?: 0;
$launcher['download']['sizeBytes'] = $lsize;
// Cache lookup pour le launcher exe aussi
$lkey = realpath($lpath) ?: $lpath;
$existingLsha = (string)($launcher['download']['sha256'] ?? '');
$hasValidLsha = $existingLsha !== '' && !str_starts_with($existingLsha, 'REPLACE');
if (!$force
&& $hasValidLsha
&& isset($cache[$lkey])
&& ($cache[$lkey]['size'] ?? -1) === $lsize
&& ($cache[$lkey]['mtime'] ?? -1) === (filemtime($lpath) ?: 0)
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
} else {
$r = $this->getOrComputeSha256($lpath, $cache);
$cacheChanged = true;
$launcher['download']['sha256'] = $r['sha256'];
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$r['sha256']} $note");
}
} else {
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
}
}
unset($launcher);
}
// Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
// hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
// qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
// ancienne version isolée via $onlyVersion.
if ($doVersions) {
$eligible = [];
foreach ($manifest['versions'] as $vEntry) {
$sha = (string)($vEntry['download']['sha256'] ?? '');
$algo = strtolower((string)($vEntry['download']['hashAlgorithm'] ?? 'sha256'));
$hasValid = ($sha !== '' && !str_starts_with($sha, 'REPLACE')) || $algo === 'none';
if ($hasValid) $eligible[] = $vEntry['version'];
}
if (!empty($eligible)) {
usort($eligible, 'version_compare');
$newLatest = end($eligible);
if (($manifest['latest'] ?? null) !== $newLatest) {
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
$manifest['latest'] = $newLatest;
}
}
}
// Signature Ed25519
if ($this->configPath && is_file($this->configPath)) {
$config = require $this->configPath;
$sk = $config['ed25519']['private_key_hex'] ?? '';
if ($sk !== '' && strlen($sk) === 128) {
$manifest['signature'] = null;
$cryptoPath = dirname($this->configPath) . '/lib/Crypto.php';
if (is_file($cryptoPath)) require_once $cryptoPath;
if (class_exists('\PSLauncher\Crypto')) {
$payload = \PSLauncher\Crypto::canonicalJson($manifest);
$manifest['signature'] = \PSLauncher\Crypto::signEd25519($payload, $sk);
$this->out(" [sign] manifest signé (Ed25519)");
} else {
$this->out(" [warn] Classe \\PSLauncher\\Crypto introuvable, manifest non signé");
}
} else {
$this->out(" [warn] ed25519.private_key_hex non configuré, manifest non signé");
}
}
$jsonOut = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
if (file_put_contents($this->manifestPath, $jsonOut) === false) {
$this->out("Échec d'écriture du manifest : {$this->manifestPath}");
return ['ok' => false, 'log' => $this->log];
}
// Persiste le cache de hashs si on l'a touché. On nettoie aussi les entrées
// obsolètes (fichiers supprimés) pour éviter qu'il grossisse indéfiniment.
if ($cacheChanged) {
foreach (array_keys($cache) as $cachedPath) {
if (!is_file($cachedPath)) unset($cache[$cachedPath]);
}
$this->saveHashCache($cache);
}
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
return ['ok' => true, 'log' => $this->log];
}
}