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>
This commit is contained in:
@@ -310,7 +310,10 @@ public sealed class LicenseService : ILicenseService
|
||||
/// </summary>
|
||||
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
|
||||
{
|
||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature
|
||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature.
|
||||
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
|
||||
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
|
||||
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
@@ -319,6 +322,8 @@ public sealed class LicenseService : ILicenseService
|
||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["channel"] = response.Channel, // null si default
|
||||
["canSeeBetas"] = response.CanSeeBetas,
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
@@ -347,8 +352,18 @@ public sealed class LicenseService : ILicenseService
|
||||
var pkBytes = Convert.FromHexString(publicKeyHex);
|
||||
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
||||
var sig = Convert.FromBase64String(base64Signature);
|
||||
var payload = CanonicalBytesFor(response);
|
||||
return alg.Verify(pk, payload, sig);
|
||||
|
||||
// Tente d'abord le canonical actuel (avec channel + canSeeBetas).
|
||||
// Si ça échoue, tente le canonical legacy (sans ces champs) — c'est
|
||||
// le format des réponses signées par les serveurs OU lancées par
|
||||
// les clients d'avant v0.26. Sans ce fallback, toutes les licenses
|
||||
// cachées localement avant l'update à v0.26 deviendraient invalides
|
||||
// et l'utilisateur serait forcé de revalider online — bloqué hors-ligne.
|
||||
var payloadCurrent = CanonicalBytesFor(response);
|
||||
if (alg.Verify(pk, payloadCurrent, sig)) return true;
|
||||
|
||||
var payloadLegacy = CanonicalBytesForLegacy(response);
|
||||
return alg.Verify(pk, payloadLegacy, sig);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -356,6 +371,33 @@ public sealed class LicenseService : ILicenseService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical historique (avant v0.26.0) : pas de <c>channel</c> ni
|
||||
/// <c>canSeeBetas</c>. Conservé pour valider les réponses cachées par
|
||||
/// les anciennes versions du client. Une fois que toutes les licenses
|
||||
/// auront été re-validées online avec le nouveau serveur, on pourra
|
||||
/// retirer cette méthode (mais on prévient pas de coût à la garder).
|
||||
/// </summary>
|
||||
private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
["licenseId"] = response.LicenseId,
|
||||
["ownerName"] = response.OwnerName,
|
||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
|
||||
}
|
||||
|
||||
// ----- Public key embarquée -----
|
||||
|
||||
private static string? TryReadEmbeddedPublicKey()
|
||||
|
||||
Reference in New Issue
Block a user