feat(license): online revocation check — a revoked key falls back to Free (#365) #368
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#368
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-365-online-revocation-check"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #365
Refset non une fermeture automatique : le dernier critère d'acceptation demande une vérification manuelle avec une licence réellement révoquée, que seul Max peut faire (voir « Vérification manuelle due »).Ce que la PR change
Constat du go-live (2026-09-30) : une licence remboursée, révoquée côté serveur, restait pleinement fonctionnelle dans l'app, mises à jour automatiques comprises. L'édition se résout hors ligne depuis la clé seule, et le signalement de #347 (D13) ne vivait que le temps de la session, sans toucher l'édition.
Rust (
license_commands.rs,balance_commands.rs,lib.rs)license.check, à côté de la clé :{key_sha256, attempted_at, revoked}. La clé n'y figure que par son condensé SHA-256. Absent, illisible, JSON invalide ou condensé d'une autre clé : « pas d'état », donc non révoquée et contrôle dû.entitlements_from_dirrend("free", [])pour une clé que l'état dit révoquée.get_edition,check_entitlement(auto-update) et la matrice du front suivent sans autre changement.read_licenseetstore_licenserendentrevoked.check_license_revocation:POST /v1/licenses/verifyavec le produit et la clé, rien d'autre. Rien ne part sans une clé enregistrée qui se valide localement, ni quand la dernière tentative a moins de 24 h.attempted_atest écrit avant l'appel. Fail-open : seule une réponsevalid:truelève l'état, seule une réponse « révoquée » le pose.license_revokedsous la clé qu'elles viennent d'envoyer (note_revocation).fetch_price_innerliterror.coded'un 403 et rendlicense_revokedau lieu depremium_required. Il n'écrit rien (premier écart ci-dessous).Front
LicenseContextlit l'état depuis Rust (info.revoked). Le drapeau de sessionLICENSE_REVOKEDetmarkRevokeddisparaissent. Le contrôle part une fois par démarrage, écrit son issue dans le Journal de l'app, et ne recharge le contexte que si l'état a changé.performSubmitKeyrelit l'édition depuis Rust au lieu de prendre celle de la clé.useLicenseMachinesrecharge le contexte après unlicense_revoked, puisque Rust vient de l'enregistrer.UpsellGate, nouvelle cléupsell.revoked) nomment la révocation. Pas de bandeau global (D2). La carte Mises à jour, en Gratuite, reprend la même ligne (décision de Max du 2026-10-03, à la revue) ; la page d'erreur reste sans.Docs : amendement #365 de l'ADR 0017 (D13 remplacée, la première requête non sollicitée et ses bornes),
docs/architecture.md, guide utilisateur etdocs.*(clé révoquée, ce que l'app envoie et quand), annexe du contrat des cours,CHANGELOG.mdetCHANGELOG.fr.md. L'entrée #347 du changelog qui décrivait le drapeau de session est réécrite plutôt que contredite : les deux changements sortiront dans la même release.Ce que cette PR change au principe « rien ne part sans action »
C'est la première requête que l'app envoie d'elle-même. Vérifié dans le code avant de l'écrire dans le guide : aucune autre requête ne part au démarrage (les mises à jour, les cours et la rétroaction partent sur un clic ; le contrôle d'un compte Maximus connecté part à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction). Les bornes, chacune gardée par un test :
productetlicense_key, rien d'autre ;Une clé connue comme révoquée reste contrôlée au même rythme, tant qu'elle est enregistrée (décision de Max du 2026-10-03, à la revue) : une correction faite côté serveur est ainsi reprise au démarrage suivant. C'est déclaré dans le guide et dans les docs intégrées. Aucune interface ne retire une clé enregistrée : seul l'enregistrement d'une autre clé arrête l'envoi de la précédente.
La mention dans la politique de confidentialité du site est suivie par maximus/la-compagnie-maximus#184 : à publier avant la release qui embarque ce contrôle.
Écarts assumés par rapport au corps de l'issue
license_revokeddes cours pose l'état. Le plan checker a trouvé pourquoi ce serait faux :/v1/pricesjuge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (lesubdu jeton est l'identifiant de la licence, la clé porte le courriel), et la v0.15.0 n'effaçait pas le jeton quand la clé changeait (git show v0.15.0:src-tauri/src/commands/license_commands.rs,store_license). Un dossier écrit par la v0.15.0 peut donc contenir une clé valide à côté du jeton d'une licence révoquée : enregistrer ce refus aurait rétrogradé la clé valide. Le message des cours nomme l'activation, pas la clé. Le jeton périmé lui-même est suivi en #367.ready, qu'une clé soit enregistrée ou non : une clé saisie en cours de session est contrôlée au démarrage suivant. C'est la lecture stricte de D1, et cela évite qu'une saisie de clé déclenche une requête que l'utilisateur n'a pas demandée.PriceFetchControln'est pas touché, et la saisie d'un snapshot en cours n'est jamais interrompue.no_license(rien n'est parti, faute de clé valide) etstorage(la tentative n'a pas pu être écrite, et rien n'est parti ; ou le verdict du serveur ne l'a pas pu, et rien n'a changé).Preuves
cargo test: 187 tests verts (168 avant).npm test: 1348 tests verts (1314 avant).npm run build(tsc + vite) vert,cargo checksans avertissement.git checkout, arbre vérifié propre à la fin : 33 rouges sur 33 côté Rust, 27 rouges sur 27 côté front, plus 4 sur 4 pour les corrections de la revue (b107cf4).4225dadajoute ce qui les rend rouges :revokedexposée par le fournisseur (aucun test ne lisait la valeur du contexte) : dérivée parlicenseContextValue, fonction testée ;attempted_atécrit après la requête au lieu d'avant : un test lit maintenant le fichier d'état depuis le serveur mockito, pendant que la requête est en vol ;e24c30f: 184 tests verts avec l'intervalle à 1 h). Les deux côtés du bord sont maintenant écrits en littéraux.b107cf4, et Max a tranché les deux autres le 2026-10-03. La mutation restée verte en revue (une tentative non inscriptible envoyée quand même) passe de 0 rouge sur 185 à 1 rouge sur 187.POST /v1/licenses/verifyrépond200 {"valid":false,"reason":"not_found"}, que le contrôle lit comme « aucun changement ».Ce que les tests ne gardent pas, faute de jsdom et d'
AppHandle:performRevocationCheck) ;lib.rset son enveloppe (app_data_dir, clé publique embarquée, URL du serveur, horloge) ;Ces trois points sont exactement ce que la vérification manuelle ci-dessous exerce.
Vérification manuelle due
license.check.license.checksupprimé : aucune rétrogradation, le Journal porte « could not be reached or answered with an error, nothing changed ».Limites assumées
La page d'erreur, verrouillée en Gratuite, ne nomme pas la révocation : page de repli après un plantage, elle doit dépendre du moins de choses possible (avis de la revue, retenu par Max).
Reprises de l'issue et de l'ADR : aucun changement hors ligne (bloquer le serveur de licences suffit à garder l'édition, c'est le soft-paywall de l'ADR 0017) ; supprimer
license.checkrend l'édition jusqu'au contrôle suivant, dû dès le prochain démarrage ; une app laissée ouverte n'est pas recontrôlée ;not_foundetexpiredn'ont aucun effet ; le contrôle compte dans les 5 requêtes par minute et par adresse IP des routes de licence, que le parcours « limite de machines » consomme déjà à 4.Suites
docs/architecture.mdannonce 36 commandes Tauri (35 avant cette PR), alors quelib.rsen enregistre 42. Six commandes n'y sont pas comptées :send_feedback,get_feedback_user_agent,ensure_backup_dir,get_file_size,file_exists, etfetch_price, encore décrite comme à venir.🤖 Generated with Claude Code
CI verte sur
4225dad, lue dans les logs des deux jobs.frontend(run 446) : buildtsc+vitevert, 68 fichiers de test, 1347 tests verts.rust(run 447) :cargo check --all-targetssans aucun avertissement de compilation, garde-fou des suppressions2 checks, exit 0, 185 tests verts,cargo auditsur 666 crates sans vulnérabilité (12 avertissements autorisés ; niCargo.lockniCargo.tomlne bougent dans cette PR).Les deux logs partent bien de
4225dad(HEAD is now at 4225dad).Revue adversariale — PR #368 (#365)
Verdict : APPROVE
Le contrôle de révocation tient les bornes annoncées : rien ne part sans clé valide, une tentative par 24 h écrite avant l'appel, fail-open, et un corps limité à
product+license_key. L'édition suit l'état que Rust persiste à côté de la clé, et les écarts au corps de l'issue sont justifiés dans le code. Une borne correcte n'est gardée par aucun test, et deux points sont à trancher par Max. Rien ne bloque.Ce qui a été rejoué
4225dad, lue dans les logs : run 446 (tsc && vite build, puis 1347 tests vitest) et run 447 (Suppression guard: 2 checks, exit 0, puis 185 tests Rust).git archivede4225dad, dossier cible Cargo séparé) : vitest 1347/1347,cargo test --lib185/185.git checkoutsur la copie, propreté vérifiée après chacune : 35 rouges (détail en fin de commentaire).5dd6e05) :verifyLicenserépond{valid:false, reason:"revoked"}avant le test d'expiration, etlicenseAuthrépond 403{error:{code:"license_revoked"}}pour la licence du jeton d'activation.verdict_from_verifyetfetch_price_innerlisent exactement ces formes.git show v0.15.0:src-tauri/src/commands/license_commands.rsconfirme questore_licenseécrivait la clé sans toucheractivation.token. Enregistrer le 403 des cours aurait donc rétrogradé la clé valide de ces dossiers.useAuthn'est monté que parAccountCardetFeedbackDialog,checkForUpdaten'est branché que sur desonClick, lesetupdelib.rsne fait aucun appel réseau, et niindex.htmlnistyles.cssne chargent de ressource distante.licenseContextValueexpose encore lesfeaturessignées d'une clé révoquée, maisisEntitledcourt-circuitefreeavant l'override (src/shared/entitlements.ts:48), et Rust rend("free", []).sha256sum) :f4753f0b…89c9, conforme.pull_refde #368 avecref_action: none, et aucun mot-clé de fermeture n'apparaît dans le corps ni dans les deux commits (format conventionnel).Refstiendra au merge.build.rsse limite àtauri_build::build(), sans liste blanche de commandes applicatives, donccheck_license_revocationne demande aucune permission.Suggestions (non bloquantes)
Épingle manquante : une tentative non inscriptible ne doit rien envoyer. Mutation R21 (ignorer l'échec de la première écriture et envoyer quand même) : 185 verts sur 185. Le code est correct (
license_commands.rs:599-601), mais sans test, un dossier de données en lecture seule enverrait une requête à chaque démarrage, et la borne (2) tomberait sans un rouge. Ce test passe sur la tête et rougit sous R21 (left: Unreachable,right: Storage), vérifié sur la copie :storagene veut pas toujours dire « rien n'est parti ».check_revocation_inrendStorageà deux endroits : avant la requête (:599-601, rien n'est parti) et après, quand le verdict a changé mais ne peut pas être écrit (:621-622, la requête est partie et le serveur a répondu). Le commentaire de la variante Rust le dit (« The attempt or its verdict could not be written »). En revanchelicenseService.ts:42(«no_license,not_dueandstoragemean nothing was sent ») et la lignecheck_license_revocationdedocs/architecture.md:348affirment davantage que le code, et le message du Journal (LicenseContext.tsx:182, « the attempt could not be recorded ») est faux dans le second cas : la tentative est écrite, c'est le verdict qui ne l'est pas. Corriger ces trois phrases, ou séparer les deux issues.À trancher : une clé révoquée est vérifiée tous les jours, sans fin. Aucun chemin ne retire
license.key: seule la saisie d'une autre clé le remplace. Un acheteur remboursé qui continue en édition Gratuite enverra donc sa clé révoquée une fois par 24 h tant qu'il utilise l'app, alors que la levée n'arrive que sur correction manuelle de la base (amendement, « Fail-open »). C'est conforme à D1 tel qu'il est écrit, mais pour ces postes la requête quotidienne ne change rien tant que personne ne corrige la base. Deux options : l'écrire dans le guide et dans la politique de confidentialité (maximus/la-compagnie-maximus#184), ou arrêter le contrôle automatique quand l'état ditrevoked.À trancher (question posée par la PR) : la carte Mises à jour. Avis : ajouter la ligne
upsell.revokeddans sa branche Gratuite (UpdateCard.tsx:81-91). C'est là qu'un acheteur Base remboursé constate d'abord la perte des mises à jour automatiques, et « incluses dans l'édition Base », sans la raison, se lit comme un défaut quand on a payé Base. LaisserErrorPagetelle quelle : une page de repli après plantage doit dépendre du moins de choses possible.Vérification manuelle : rappel pratique
Enregistrer la clé révoquée efface l'activation de la clé de testeur, et ressaisir cette dernière l'efface aussi (
store_license_in, #347) : prévoir un « Activer » pour retrouver les cours. Pour rejouer le contrôle avant 24 h, supprimerlicense.check.Mutations du relecteur
Front, suite vitest complète à chaque mutation : 13 rouges sur 14.
revoked(levée non rechargée)performSubmitKeyreprend l'édition de la cléperformSubmitKeysans le drapeaurevokedlicenseContextValuene dit jamaisrevokedUpsellGatesans la ligne de révocationlicense_revokedne recharge plus le contextelicense_revokedretenté comme un 5xxlicense_revokedrelié au message premiumfalseau lieu de l'état chargéerrorrefreshnon attendu aprèslicense_revokedRust,
cargo test --libcomplet à chaque mutation : 22 rouges sur 23.entitlements_from_dirignore l'étatvalid:falsevaut révoquéenote_revocationenregistre toute erreurmachine_idlicense_revokedde l'enveloppe lu comme injoignablestore_licensene rend jamaisrevokedis_revoked_invrai pour tout état de la clépremium_requiredattempted_atrepris de l'ancien étatread_licensene rend jamaisrevokedvalid:trueexige une liste de machinesnote_revocationsans horodatage de tentativeActions faites
/tmp/pr368-copy, dossier cible/tmp/pr368-target, script de mutations et logs CI/tmp/pr368-*: supprimés en fin de revue. Le dépôt de travail n'a pas été touché (git statusvide, tête4225dad).status:reviewremplacé parstatus:approved.Revue appliquée dans
b107cf4. Ce commit n'a pas été relu : la revue (APPROVE, commentaire 18362) portait sur4225dad. Max a demandé d'appliquer puis de merger.Suggestions 1 et 2, appliquées
an_attempt_that_cannot_be_written_sends_nothing), avec un second test pour l'autre cas destorage: le verdict ne peut pas être écrit, et la clé n'est pas dite révoquée tant que rien sur le disque ne le dit (a_verdict_that_cannot_be_written_is_not_reported_as_applied).storagene prétend plus que rien n'est parti :licenseService.ts, la ligne du Journal etdocs/architecture.mdcouvrent maintenant les deux cas.Suggestions 3 et 4, tranchées par Max le 2026-10-03
upsell.revokedest ajoutée à la carte Mises à jour en Gratuite, entre le message et les boutons. La page d'erreur ne change pas.Preuve du rouge (étape 6a bis)
4225dad)b107cf4)Chaque mutation a été appliquée après le commit, vérifiée par
git diff, puis restaurée pargit checkout, et l'arbre est vérifié propre à la fin. En local :cargo check --all-targetssans avertissement,npm run buildvert.CI verte sur
b107cf4, lue dans les logs.frontend(run 449) : build vert, 1348 tests verts.rust(run 450) : garde-fou des suppressions2 checks, exit 0, 187 tests verts,cargo auditsur 666 crates sans vulnérabilité (12 avertissements autorisés), aucun avertissement de compilation. Merge en fast-forward demandé par Max.