Files
PS_Launcher/server/tools/sign-manifest.php
j.foucher 7a29dbb049 v0.4: licensing — server validation, DPAPI cache, Ed25519 signed responses
UX bonus: clicking "Reprendre" on an interrupted download skips the
release-notes confirmation dialog (the user already approved when they
first clicked Installer).

Server side
-----------
- migrations/001_init.sql: licenses, license_machines, rate_limit,
  audit_log on InnoDB/utf8mb4. Foreign keys, unique on license_key, slot
  uniqueness per (license_id, machine_id).
- api/lib/Db.php: thin PDO singleton (exception mode, prepared, no emul).
- api/lib/Crypto.php: Ed25519 sign/verify via libsodium (sodium_crypto_*),
  HMAC-SHA-256 helper for v0.6, canonicalJson() that strips `signature`
  before serializing — must match exactly the encoding done client-side
  before verify.
- api/routes/ValidateLicense.php: POST /license/validate. Looks up the
  key, walks the machine slot logic (insert or update last_seen),
  enforces max_machines, returns a payload signed Ed25519 + status of
  valid/expired/revoked/machine_limit_exceeded/invalid. Audit logs every
  outcome. Rate-limit 10/min/IP via the rate_limit table.
- tools/generate-keypair.php: prints a fresh sodium keypair so the
  operator drops the hex into config.php and the public_key_hex into the
  launcher resource.
- tools/issue-license.php: PRSRV-XXXX-XXXX-XXXX-XXXX generator (32-char
  unambiguous alphabet), inserts the license, prints the key once.
- tools/sign-manifest.php: now also signs the manifest itself with
  Ed25519 after computing the per-zip sha256s.
- config.example.php: schema rewritten with sections db / hmac / ed25519
  / jwt / rate-limit. config.php remains gitignored.

Client side
-----------
- Models/License.cs: LicenseValidationRequest + LicenseValidationResponse
  with CanDownload(VersionManifest) — entitlement_until vs version's
  minLicenseDate. The status valid|expired|revoked|machine_limit_exceeded
  flow is preserved end-to-end.
- Core/Licensing/LicenseService.cs:
  * machineId = SHA-256 of HKLM/Software/Microsoft/Cryptography/MachineGuid
    + UserName (stable, no PII leak)
  * online ValidateAsync calls /license/validate with launcher version
  * embedded server-pubkey.txt drives Ed25519 verification of the
    response (skipped gracefully if pubkey not yet provisioned)
  * SaveCached / GetCached use DPAPI CurrentUser scope on the license
    key; the cleartext key never touches disk
  * GetCached has a 7-day offline grace window after the last successful
    validation, so going offline doesn't lock the user out
- Core/Resources/server-pubkey.txt: EmbeddedResource. Default content is
  a comment, which the service treats as "no pubkey configured" and
  bypasses verification. Operator pastes the real hex post-deploy and
  rebuilds.
- Core/PSLauncher.Core.csproj: Polly, NSec.Cryptography (Ed25519),
  System.Security.Cryptography.ProtectedData (DPAPI).
- App/Views/OnboardingDialog.xaml(.cs): first-launch / "🔑 Activer"
  modal. Calls LicenseService, displays status messages with red
  foreground on errors and green-tinted secondary text otherwise.
- ViewModels/VersionRowViewModel.cs: new LicenseAllowsDownload property.
  Install button label switches to "🔒 License insuffisante" when the
  user's entitlement_until precedes the version's minLicenseDate;
  CanInstall is false in that case so the click is a no-op too.
- ViewModels/MainViewModel.cs: loads the cached license at startup (no
  network call), surfaces it as LicenseSummary in the top bar, exposes
  ActivateLicenseCommand to (re)open the onboarding dialog. RebuildList
  applies the per-version license filter so older installed versions
  remain launchable but newer-than-license ones can't be downloaded.
- Views/MainWindow.xaml: top bar gains a "🔑 Activer / changer" button
  next to the license summary.

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

105 lines
3.7 KiB
PHP

<?php
/**
* Met à jour les champs `download.sizeBytes` et `download.sha256` de versions.json
* en lisant les ZIPs présents dans /builds/. Le nom local du ZIP est dérivé du
* champ `download.url` (basename de l'URL), donc tu peux nommer ton fichier comme
* tu veux tant que le manifest pointe vers le bon nom.
*
* Usage (à exécuter via SSH OVH dans www/PS_Launcher/) :
* php tools/sign-manifest.php
*
* (v0.4) Ajoutera la signature Ed25519 globale du manifest.
*/
declare(strict_types=1);
$root = dirname(__DIR__);
$manifestPath = "$root/manifest/versions.json";
$buildsDir = "$root/builds";
if (!is_file($manifestPath)) {
fwrite(STDERR, "Manifest not found: $manifestPath\n");
exit(1);
}
$manifest = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
$updated = 0;
$hashedVersions = [];
foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?';
$url = $v['download']['url'] ?? '';
if ($url === '') {
echo " [skip] $version : pas d'URL dans le manifest\n";
continue;
}
$filename = basename(parse_url($url, PHP_URL_PATH) ?: '');
$zip = "$buildsDir/$filename";
if (!is_file($zip)) {
// Fallback : si le fichier n'existe pas exactement avec le nom de l'URL,
// on tente une recherche tolérante par version (espaces / casse / séparateurs).
$candidates = glob("$buildsDir/*{$version}*.zip", GLOB_NOSORT) ?: [];
$candidates = array_values(array_filter($candidates, 'is_file'));
if (count($candidates) === 1) {
$zip = $candidates[0];
echo " [info] $version : URL pointait vers '{$filename}', utilisé '" . basename($zip) . "' à la place\n";
} else {
echo " [skip] $version : ZIP introuvable pour $url\n";
if (count($candidates) > 1) {
echo " Plusieurs candidats : " . implode(', ', array_map('basename', $candidates)) . "\n";
}
continue;
}
}
$size = filesize($zip);
echo " [hash] $version : " . basename($zip) . " ($size octets)...";
$sha = hash_file('sha256', $zip);
echo " sha256={$sha}\n";
$v['download']['sizeBytes'] = $size;
$v['download']['sha256'] = $sha;
$updated++;
$hashedVersions[] = $version;
}
unset($v);
// Met à jour automatiquement le champ `latest` avec la plus haute version
// effectivement uploadée (celle pour laquelle on a calculé un hash).
if (!empty($hashedVersions)) {
usort($hashedVersions, function ($a, $b) {
return version_compare($a, $b);
});
$newLatest = end($hashedVersions);
if (($manifest['latest'] ?? null) !== $newLatest) {
echo " [latest] {$manifest['latest']} -> {$newLatest}\n";
$manifest['latest'] = $newLatest;
}
}
// (v0.4) Signature Ed25519 du manifest
$configPath = dirname(__DIR__) . '/api/config.php';
if (is_file($configPath)) {
require_once dirname(__DIR__) . '/api/lib/Crypto.php';
$config = require $configPath;
$sk = $config['ed25519']['private_key_hex'] ?? '';
if ($sk !== '' && strlen($sk) === 128) {
// Retire signature précédente, encode canonical, signe, ré-injecte
$manifest['signature'] = null;
$payload = \PSLauncher\Crypto::canonicalJson($manifest);
$manifest['signature'] = \PSLauncher\Crypto::signEd25519($payload, $sk);
echo " [sign] manifest signed (Ed25519)\n";
} else {
echo " [warn] ed25519.private_key_hex non configuré, manifest non signé\n";
}
} else {
echo " [warn] config.php absent, manifest non signé\n";
}
file_put_contents(
$manifestPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
echo "Manifest mis à jour ({$updated} version(s)) : $manifestPath\n";