fix(license): named key-entry errors, edition independent of the activation token, revoked key flagged (#347) #364
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#364
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-347-license-card"
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?
Fixes #347
Fixes #360
Refs #363
Ce que la PR change
Rust (
license_commands.rs)LicenseErrorgagneInvalidPrefix,InvalidSignature,MalformedetUnknownEdition.validate_with_keyle renvoie (ExpiredSignaturedevientLicenseExpired), etstore_license,read_licenseetvalidate_license_keyrejettent avec le même JSON que les commandes réseau.store_license_inetread_license_inprennent un dossier, ce qui teste la correspondanceStorageà la source.resolve_license_entitlementsne prend plus le jeton d'activation, etcurrent_entitlementsne lit plus quelicense.key(entitlements_from_dir).get_activation_statusetpersist_activation_tokengardent la validation stricte du jeton ; la clé garde la sienne (expetiatexigés, CWE-613).Front
LicenseCardnomme l'erreur de chargement, etLicenseKeyForm(extrait) nomme une clé refusée, viaparseLicenseError: aucune chaîne brute n'atteint la carte.license_revokedest consigné dansLicenseContextpour toute la session ; message au niveau de la carte, icône d'alerte, « Activer » désactivé viaselectCanActivate.fr-CA/en-CA, « Sans échéance » au-delà de 50 ans (licenseExpiry.ts).license.removeLicenseetlicense.machines.activateErrorretirées ; le messageauthFaileddes cours renvoie vers l'activation.Docs : CHANGELOG FR/EN, guide §12 (nouvelle section « La carte Licence ») et docs intégrées,
architecture.md, amendement #347 de l'ADR 0017,qa-update-cycle.md,api-contract-prices.md.Écarts assumés par rapport au corps de l'issue
LicenseContext, pas dansLicenseMachinesState. Le reducer du hook meurt avec la carte : quitter Paramètres → Utilisateurs ou changer de profil l'aurait effacé, et « Activer » serait redevenu cliquable.LicenseContextest monté une fois dansmain.tsx, au-dessus deProfileProvider. Relevé par le plan checker.LicenseKeyFormest extrait : le formulaire était derrière un état interne de la carte, donc hors de portée d'un rendu statique. Sans l'extraction, remettre la chaîne brute dans le formulaire passait la suite.refresh()reste appelé après une activation ou une désactivation, commentaire corrigé : ni l'une ni l'autre ne change plus l'édition.qa-update-cycle.mdest nuancé, pas vidé : le cycle de test part d'une version publiée, et v0.15.0 applique encore l'ancienne règle.Preuves
Rouge avant le correctif, sur
5d549d6:left: "free",right: "base").Suites sur
d2ded51:cargo test128 → 140 ; vitest 1266 → 1305 (68 fichiers) ;npm run build(tsc + vite) vert ;cargo checksans avertissement.Mutations rejouées sur l'arbre commité, 27 sur 27 rouges (chaque fichier restauré depuis
HEAD, arbre propre vérifié) :activation.tokenprésent refait tomber en Gratuite (D12 annulé)ExpiredSignatureplus nomméeexpde la clé plus appliqué (CWE-613)expires_attronqué à 32 bits (#363)store_license_inécrit avant de validerexpdu jeton plus appliquéLICENSE_REVOKEDignorérevokeddu contextemarkRevokedselectCanActivateignorerevokedinfodu compteur retiréenoExpiryabsent deen.jsonremoveLicenseremis dansen.jsonÀ vérifier à la main
abccomme clé : le message parle du préfixeSR-BASE-/SR-PREMIUM-.activation.tokenpar celui d'une autre machine : l'édition reste, la carte affiche « Non activée » et « Activer ».Hors périmètre
/v1/pricesne vérifie pas que la machine est encore active (maximus-api, issue à ouvrir)./issue-licenseaprès le déploiement de maximus-api#63, et essai d'une vraie clé de 100 ans.🤖 Generated with Claude Code
CI verte au head
d2ded51, vérifiée dans les logs :check-frontend) :HEAD is now at d2ded51, build (tsc + vite) OK, 68 fichiers, 1305 tests vitest.check-rust) :HEAD is now at d2ded51,cargo checkOK, garde-fou exécuté (Suppression guard: 2 checks, exit 0),cargo test140 passés,cargo auditsans vulnérabilité (12 avertissements autorisés, comme surmain).Revue adversariale — PR #364 (#347, #360)
Verdict : APPROVE
Résumé — D12, les erreurs nommées et la révocation de session tiennent, y compris sous des mutations du relecteur : D12 annulé fait rougir 2 tests Rust, et
LOAD_DONEouLOAD_ERRORqui effacent la révocation en font rougir 1 chacun. Trois chemins restent sans garde (0 rouge chacun), et le messageinvalidSignaturedéclare « non émise par le serveur » une clé authentique mal copiée dans environ une troncature sur trois (3 longueurs sur 8 mesurées). Rien de bloquant.Vérifié
d2ded51, lue dans les logs et pas sur la pastille : run 434 (rust)140 passed, garde-fouSuppression guard: 2 checks, exit 0; run 433 (frontend)tsc && vite buildvert, 68 fichiers / 1305 tests.git archive d2ded51, target Cargo séparé dans le scratchpad) :cargo test --lib140, sans avertissement ; vitest 1305. Le dépôt n'a pas été touché.pull_refclosesvenant de #364 ; #363 portenone. LeRefs #363est respecté.user@example.comseulement).licenseAuth.ts(maximus-api) répond 401 à un jeton absent, expiré ou invalide : le nouveauauthFailed(« activez cette machine ») vise le bon cas.entitlements_from_dir) ; clé toujours validée strictement (expetiatexigés, leeway 0, nouveaurejects_license_without_exp) ;get_activation_statusetpersist_activation_tokengardent la validation stricte du jeton ; compteur, « Non activée », libellés en cours, datesfr-CA/en-CA, « Sans échéance », clés retirées ; docs à jour (CHANGELOG ×2, guide §12 etdocs.editions.tips8/8 dans les deux langues,architecture.md, amendement de l'ADR 0017).Mutations du relecteur
Sur la copie ; chaque fichier restauré depuis sa sauvegarde et comparé octet pour octet.
entitlements_from_dir: jeton relu et validé strictement contremachine_id_internal()ErrorKind::InvalidSignaturelu commeMalformedLOAD_DONEeffacerevoked(toutrefresh()l'effacerait, dont celui qui suit une désactivation réussie)LOAD_ERROReffacerevokedget_activation_statuslit tout jeton lisible comme activéshownUnderMachinesmasque toutes les erreurs sous MachinesDeux autres mutations restent vertes, sans conséquence pratique :
isSameLicensesansexpires_at, et le compteur affiché à côté d'une erreur de désactivation.Suggestions (non bloquantes)
src/components/settings/LicenseCard.tsx:53-57— le filtreshownUnderMachinesn'a aucun garde à l'affichage. Le remplacer parnullfait disparaître toutes les erreurs sous Machines (limite de machines, trop de tentatives, réseau) et laisse les 1305 tests verts. Le parcours D2 de #270, où le message de limite reste affiché pendant le chargement de la liste, n'est gardé qu'au niveau du reducer, et c'est cette PR qui insère un filtre sur son chemin d'affichage. Deux rendus suffisent :activationError: machine_limit_reached→license.errors.machineLimitReachedprésent ;listOpenaveclistError: network→license.errors.networkprésent.src-tauri/src/commands/license_commands.rs:686(get_activation_status) — la décision du critère 3 n'est pas testée à son point. Depuis D12, cette fonction décide seule de « Non activée » et du bouton « Activer », c'est-à-dire de la sortie de l'impasse de #360. La faire répondretruepour tout jeton lisible laisse 140/140 verts : seulsvalidate_activation_with_keyet ses deux refus sont testés, ce que l'issue demandait. Le découpage déjà appliqué àentitlements_from_dirs'y transpose :activation_status_in(dir, machine_id, key), testée dans unTestDir(jeton absent, expiré, d'une autre machine ou illisible →false; jeton valide →true).license.errors.invalidSignature(FR/EN, ligne 1220) et guide §12 (ligne 561) — une clé authentique mal copiée est déclarée « non émise par le serveur de licences ». Mesuré sur une clé correctement signée, avec un test sonde temporaire dans la copie :MalformedInvalidSignatureInvalidSignatureMalformedjsonwebtokenvérifie la signature suren-tête.chargeavant de décoder la charge : toute altération de la charge, et une partie des troncatures de la signature, finissent enInvalidSignature. Un retour à la ligne collé dans le champ y est retiré ou changé en espace selon le moteur ; dans le second cas, on retombe surInvalidSignature. Le guide range « copiée en partie ou coupée par un retour à la ligne » sous « illisible », et le messagemalformedconseille « sans espace », alors que ces cas affichent souvent « Cette clé de licence n'a pas été émise par le serveur de licences de Simpl'Résultat. ». Sur l'écran qu'un acheteur voit quand sa clé pose problème, ce message suggère une clé contrefaite. Formulation proposée : « Cette clé de licence n'a pas pu être vérifiée. Vérifiez qu'elle a été copiée en entier ; si c'est le cas, elle n'a pas été émise par le serveur de licences de Simpl'Résultat. », avec la puce du guide alignée.src/components/settings/LicenseCard.tsx:79-84— l'icône d'alerte de la tâche 4 n'est pas gardée. Garder laCheckCirclepour une clé révoquée laisse la suite verte. Une assertion suffit : clé révoquée et machine non activée → aucune classelucide-circle-check-bigdans le rendu. La machine doit être non activée, car le badge « Activée » porte la même icône.Hors PR, trouvés en route
store_license_inn'écrit quelicense.key. Le jeton porte ensubl'UUID de l'ancienne licence, la clé porte le courriel : le client ne peut pas les rapprocher, etget_activation_statusne vérifie que la signature,expetmachine_id. La carte affiche donc « Activée », sans bouton « Activer », pendant que/v1/pricesauthentifie l'ancienne licence (licenseAuth.ts: licence révoquée → 403license_revoked, licence Base → 403premium_required). Cas concernés : rachat après un remboursement, réémission après une révocation, passage de Base à Premium. L'argument de la PR pour effacerrevokedà l'enregistrement d'une autre clé (« une clé de remplacement bloquée jusqu'au redémarrage serait un piège ») n'atteint pas ce sous-cas : la clé de remplacement reste bloquée, par le jeton cette fois. Correctif simple :store_license_insupprimeactivation.tokenquand la clé enregistrée change, testé dans unTestDir.npm ciannonce 7 vulnérabilités (3 moderate, 4 high) dans les runs 430 (#362) et 433, alors queCLAUDE.mdn'en décrit que 2 high acceptées (#317).Revue appliquée :
3f90635Les quatre suggestions et le défaut trouvé hors PR sont traités dans un seul commit,
fix(license): apply the review of #364.machine_limit_reached(parcours D2 de #270) et une erreur réseau de la liste restent affichés. Seule la révocation passe au niveau de la carte.get_activation_statuspasse paractivation_status_in(dir, machine_id, key), testée dans un dossier temporaire. Un jeton valide rendtrue; un jeton absent, expiré, d'une autre machine, signé par une autre clé ou illisible rendfalse.invalidSignature: la formulation proposée est reprise en FR et en EN (« n'a pas pu être vérifiée. Vérifiez qu'elle a été copiée en entier ; si c'est le cas… »), et le guide §12 ainsi que les deux CHANGELOG sont alignés. Un test de caractérisation épingle la cause : une espace glissée dans la charge utile ressort enInvalidSignature, parce quejsonwebtokenvérifie la signature avant de décoder la charge.lucide-circle-check-big; sans révocation, il la contient.store_license_inet le critère « peut (ré)activer » de #347) :store_license_insupprimeactivation.tokenquand la clé enregistrée change. La même clé ressaisie garde son jeton, une clé refusée ne touche à rien, et un jeton sans clé est supprimé. Côté carte,LicenseCardporte la clé ReactlicenseIdentity(info): une autre clé remonte le corps, qui relit l'activation et oublie la liste de la clé précédente.Avant / mutation / après
« Avant » = mesure du relecteur sur
d2ded51. « Mutation » = même mutation sur3f90635. « Après » = fichier restauré depuisHEADet comparé ; la suite complète est verte sur cet arbre (local et CI ci-dessous).get_activation_statuslit tout jeton lisible comme activéshownUnderMachinesmasque toutes les erreurs sous MachinesErrorKind::InvalidSignaturelu commeMalformedstore_license_ingarde le jeton d'une clé précédentestore_license_insupprime aussi le jeton de la même cléLicenseCardsans clé React liée à la licencelicenseIdentitysans la date d'émissionCI
check-rust) :HEAD is now at 3f90635, garde-fou exécuté (Suppression guard: 2 checks, exit 0),cargo test151 passés,cargo auditsans vulnérabilité (12 avertissements autorisés).check-frontend) :HEAD is now at 3f90635, build (tsc + vite) OK, 68 fichiers, 1308 tests.Les 7 vulnérabilités npm relevées en passant sont suivies par #358.
Revue adversariale — PR #364 (#347, #360), tête
3f90635Verdict : APPROVE
Résumé — La revue précédente est appliquée et tient sous des mutations du relecteur : chaque mutation tentée sur le code neuf de
3f90635(jeton supprimé au changement de clé,activation_status_in, carte indexée par la licence) fait rougir la suite, et les 151 tests Rust et 1308 tests vitest passent, en CI comme dans une copie hors dépôt. Restent une course étroite qui peut reposer le jeton de l'ancienne clé, un badge « Activée » vert pour une clé révoquée et un renvoi incomplet dans l'ADR 0017. Rien de bloquant.Vérifié
3f90635, lue dans les logs : run 436 (frontend)HEAD is now at 3f90635,tsc && vite buildvert, 68 fichiers / 1308 tests ; run 437 (rust)HEAD is now at 3f90635,Suppression guard: 2 checks, exit 0,151 passed, aucun avertissement du compilateur,cargo audità 12 avertissements autorisés.git archive 3f90635, target Cargo dans le scratchpad) :cargo test --lib151, vitest 1308 sur 68 fichiers. Le dépôt n'a pas été touché.pull_refclosesvenant de #364 sur #347 et #360,nonesur #363. Aucun mot-clé de fermeture dans les commentaires de la PR.user@example.comseulement), aucun.only,.skipou#[ignore], commits au format conventionnel.license.machines.count, qui n'a pas de formes plurielles, retombe sur la clé nue : « 1/3 machines activées », « 2/3 machines activées », « 1/3 machines activated ».noExpiry,invalidSignatureetauthFailedse résolvent dans les deux langues.ac91da4) :licenseAuth.tsrépond 401 à un jeton absent, expiré ou invalide, ce que vise le nouveauauthFailed;revokeLicensepasse toutes les activations de la licence àis_active = false.Mutations du relecteur (sur
3f90635)Copie hors dépôt ; chaque fichier restauré depuis sa sauvegarde et comparé par sha256.
store_license_incompare le courriel des deux clés au lieu des clés (un passage Base → Premium du même acheteur garderait le jeton)store_license_inne supprime le jeton que si une clé était déjà stockéeactivation_status_inignore l'expdu jetonLicenseCardindexée par le seul courrielLicenseCardpassevalidationError={null}au formulaireuseLicenseMachinesne lit plus l'activation au montageLes deux mutations vertes ne demandent pas d'action, mais elles bornent ce que la suite prouve :
showInput, qu'un rendu statique n'ouvre jamais : l'extraction deLicenseKeyFormgarde le formulaire, pas la prop que la carte lui passe. La prop étant requise, le risque est faible.renderToStaticMarkupn'exécute pas les effets.3f90635s'appuie dessus (« une autre clé remonte le corps, qui relit l'activation ») : le remontage est prouvé par le test de clé, la relecture ne l'est pas. La garder demanderait un rendu client.Suggestions (non bloquantes)
Une activation en vol peut reposer le jeton de la clé précédente.
activate_machine_impl(src-tauri/src/commands/license_commands.rs:635-648) lit la clé stockée (l. 636), attend le serveur jusqu'à 15 s (l. 639-645), puis écrit le jeton (l. 647) sans relire la clé. Pendant cet appel, « Entrer une clé de licence » reste cliquable (src/components/settings/LicenseCard.tsx:127-135) et l'envoi du formulaire n'est bloqué que parvalidating(LicenseKeyForm.tsx:53). Si une autre clé est enregistrée dans l'intervalle,store_license_insupprime le jeton, puis l'activation de l'ancienne clé en écrit un. La carte remontée affiche « Non activée » ou « Activée » selon l'ordre des deux écritures, et « Activée » à la visite suivante. On retombe alors dans l'état que3f90635ferme : cette machine absente de la liste de la nouvelle clé (aucun « Désactiver »), pas de bouton « Activer », la ressaisie de la même clé garde le jeton, et/v1/pricesauthentifie l'ancienne licence (révoquée ou Base : 403). La seule sortie dans l'interface est d'enregistrer une autre clé, puis la bonne.La fenêtre est étroite et le correctif tient en une ligne : désactiver « Entrer une clé de licence » tant que
machines.activatingest vrai, ce qu'un rendu statique vérifie (hookView({ activation: notActivated, activating: true })→ le boutonlicense.enterKeyportedisabled; « Activer » le porte déjà dans cet état, l'assertion doit viser le bon bouton). Variante côté Rust : relire la clé stockée juste avantpersist_activation_tokenet ne rien écrire si elle a changé.Le même état existe déjà sur disque là où la clé a changé après une activation sous v0.15.0 ou un build antérieur à
3f90635: la mise à jour ne le corrige pas, la ressaisie de la clé courante non plus. En pratique, il s'agit des postes des testeurs, et la sortie est la même.Le badge « Activée » reste vert pour une clé révoquée (
LicenseCard.tsx:187-192). La révocation désactive toutes les machines côté serveur (revokeLicense, maximus-api), mais le jeton local reste valide : la carte montre le bandeau rouge et l'icône d'alerte de l'édition, puis, sous Machines, « Activée » avec la coche verte que la tâche 4 retire de l'édition. Le test de rendu le relève en commentaire. Pour une clé révoquée, masquer ce badge ou le rendre neutre aligne la section Machines sur le reste de la carte.ADR 0017 : renvoi incomplet. L'amendement #347 renvoie aux « mentions de liaison à la machine plus bas (Contexte, §5, Liens) », mais le §1 en porte aussi : « qu'elle soit native ou issue d'un déclassement machine-binding » (l. 53), et le commentaire « une clé déclassée » du bloc de code (l. 49). Ajouter §1 à la liste.
Hors PR, trouvé en route
license_revokeddes cours s'affiche comme « Cette fonction nécessite un abonnement premium ».fetch_price_inner(src-tauri/src/commands/balance_commands.rs:165) traduit tout 403 enPremiumRequiredsans lireerror.code, alors quelicenseAuth.tsdistinguelicense_revokeddepremium_requiredet quedocs/api-contract-prices.mdprévoiterrors.licenseRevoked. La clébalance.priceFetching.errors.licenseRevokedexiste dans les deux langues sans aucun appelant. Un acheteur Premium remboursé lit donc qu'il lui faut Premium. À rattacher à #365, qui reprend l'affichage de la révocation.Deuxième revue appliquée :
cdf4df8LicenseKeyFormgagne une propbusy). Pendant l'enregistrement d'une clé, « Activer » l'est aussi : l'activation aurait lu l'ancienne clé et écrit son jeton après la nouvelle. La variante Rust (relire la clé avant d'écrire le jeton) n'est pas retenue, car la carte est le seul chemin d'écriture d'une clé.Le 403
license_revokeddes cours, affiché comme « abonnement premium requis », est consigné sur #365 (commentaire du 2026-10-01).Mutations, sur l'arbre commité
Chaque fichier a été restauré depuis
HEAD, et l'arbre vérifié propre.CI
check-rust) :HEAD is now at cdf4df8, garde-fou exécuté,cargo test151 passés,cargo auditsans vulnérabilité (12 avertissements autorisés).check-frontend) :HEAD is now at cdf4df8, build (tsc + vite) OK, 68 fichiers, 1311 tests.