bug: jeton d'activation d'une ancienne licence resté à côté d'une autre clé — carte « Activée », cours refusés, aucun moyen de réactiver #367

Closed
opened 2026-10-02 18:36:42 +00:00 by maximus · 0 comments
Owner

Contexte

Un dossier de données écrit par la v0.15.0 ou une version antérieure peut contenir une clé de licence valide à côté du jeton d'activation d'une autre licence. Trouvé le 2026-10-02 par le plan checker de #365, et vérifié dans le code.

Comment l'état se crée : dans la v0.15.0, store_license écrit la clé sans toucher à activation.token (git show v0.15.0:src-tauri/src/commands/license_commands.rs). Saisir une clé, activer la machine, puis enregistrer une autre clé laisse donc le jeton de la première. #347 a corrigé la cause pour l'avenir (store_license_in supprime le jeton quand la clé change, license_commands.rs:234-247), mais ne répare pas un dossier déjà dans cet état. Le cas concret : un achat suivi d'un remboursement, puis une autre clé enregistrée avec la v0.15.0. Le jeton restant est celui d'une licence révoquée.

Ce que l'utilisateur voit

  • La carte Licence affiche « Activée » : activation_status_in (L1157) vérifie la signature, l'expiration et l'identifiant de la machine, et ce jeton les satisfait. Le client ne peut pas relier un jeton à une clé : le sub du jeton est l'identifiant de la licence, la clé porte le courriel.
  • Les cours du Bilan sont refusés : 403 license_revoked pour la licence du jeton, nommé depuis #365 (balance_commands.rs:171-181, balance.service.ts:2672-2674).
  • Aucun moyen de réactiver : « Activer » n'est pas proposé, la machine n'a en général aucune ligne sous la nouvelle clé, et ressaisir la même clé garde le jeton.

Analyse (/analyze, 2026-10-03, main 0e4fef5)

Prémisse confirmée, avec deux nuances sur la piste d'origine (réconcilier avec la liste des machines) :

  • L'heuristique « aucune ligne marquée is_this_machine → supprimer le jeton » rate le cas où la machine est aussi activée sous la clé actuelle : une ligne est marquée, et le jeton de l'autre licence reste.
  • machines_from_verify (L909-929) écarte en silence toute ligne qui ne se désérialise pas (filter_map(... .ok()), L924 ; un activated_at à null suffit). Une liste peut donc paraître complète sans l'être.
  • Le signal le plus direct est le 403 license_revoked de la route des cours : le jeton qu'elle présente appartient à une licence révoquée et ne sert plus à rien, quelle que soit la clé enregistrée.

Décision de Max (2026-10-03) : deux signaux, 403 des cours + liste.

  1. 403 license_revoked de /v1/prices → suppression de activation.token. Aucune écriture de l'état révoqué de la clé : la décision de #365 du 2026-10-02 tient, parce que ce refus juge la licence du jeton, pas la clé. Le jeton part dans tous les cas : si la clé enregistrée est elle-même révoquée, « Activer » le dira (license_revoked, enregistré par note_revocation sous la clé envoyée) ; sinon, « Activer » répare. La carte repropose « Activer » au prochain chargement de l'état d'activation.
  2. Réconciliation dans list_machines_in (L999-1008), qui a dir, la liste et les identifiants : après une réponse Ok, le jeton est supprimé si aucune ligne ne nomme ce poste (names_this_machine, formes dérivée et brute, ADR 0020), et seulement si toutes ces gardes tiennent :
    • les identifiants du poste sont lisibles (ids vaut Some) ; sinon aucune ligne n'est marquée et la suppression serait fausse ;
    • la liste est complète : aucune ligne écartée par machines_from_verify, qui rend désormais aussi le nombre de lignes écartées (l'affichage de la liste reste tolérant, pas d'erreur invalid_response) ; seule la réconciliation lit ce compteur ;
    • le jeton se valide localement pour ce poste (sinon il ne compte déjà pas comme activé) ;
    • aucune suppression sur une erreur, un license_revoked ou un rate_limit_exceeded.
      Cette règle couvre aussi une machine désactivée depuis un autre poste, qui garde aujourd'hui son jeton local (la route des cours ne vérifie pas qu'une machine est encore active, suivi côté maximus-api à ouvrir).

Côté front, useLicenseMachines relit l'état d'activation (loadActivationStatus) après LIST_LOAD_DONE, pour que « Activer » réapparaisse sans recharger la page. La liste reste chargée à la demande (budget de 5 req/min/IP) : aucune requête ajoutée.

Travail à faire

  • Rust, cours : sur FetchPriceError::LicenseRevoked, supprimer activation.token seulement si le fichier vaut encore le jeton présenté dans la requête refusée (un 403 tardif ne doit pas effacer le jeton neuf d'une réactivation), via remove_activation_token_in (helper de #370), best effort. read_stored_activation_token passe en version _in(dir) et utilise la constante ACTIVATION_FILE au lieu du nom en dur.
  • Rust, liste : machines_from_verify rend aussi le nombre de lignes écartées ; la réconciliation vit dans une fonction dédiée sans AppHandle (modèle : drop_token_of_deactivated_machine_in) appelée par list_machines_in ; list_activated_machines rend { machines, token_dropped: "not_listed" | null }.
  • Front : licenseService.ts et useLicenseMachines.ts suivent le nouveau contrat ; relire l'état d'activation après le chargement de la liste ; après un refus des cours license_revoked, le message invite à réactiver, et le message auth (requêtes du même lot parties sans jeton) invite aussi à activer (i18n FR/EN).
  • Une ligne au Journal quand un jeton est retiré (logInfo), à code fixe sur le modèle de REVOCATION_LOG (not_listed_for_this_machine, price_refused_revoked), sans jeton, identifiant de licence ni identifiant de machine (ADR 0020). Côté liste, déclenchée par token_dropped ; côté cours, par le code license_revoked.
  • Docs : docs/architecture.md:354 (« la route des cours nomme le refus sans rien écrire » devient « supprime le jeton, n'écrit pas l'état de la clé ») ; amendement #365 de l'ADR 0017 (paragraphe « Les autres sources ») ; ADR 0020 si la règle de suppression du jeton y est énumérée ; CHANGELOG.md et CHANGELOG.fr.md (Fixed/Corrigé).

Fichiers concernés

  • src-tauri/src/commands/balance_commands.rs : lecture du jeton (L102-111), 403 (L171-181).
  • src-tauri/src/commands/license_commands.rs : machines_from_verify, flag_this_machine (L978), list_machines_in, activation_status_in.
  • src/hooks/useLicenseMachines.ts, src/services/balance.service.ts, i18n.

Surface de test

  • Existants : activation_status_* (L1715-1768), the_machine_list_flags_this_machine_under_either_identifier (L1936), deactivation_drops_the_token_that_stood_for_the_freed_slot (L2002), verify_answers_map_to_machines_or_codes (L2850), verify_revoked_key_is_a_refusal_not_an_empty_list (L3075), verify_rate_limit_and_html_outage (L3089) ; it_names_a_revoked_license_on_403 (balance_commands.rs:451) ; useLicenseMachines.test.ts.
  • À ajouter (régression requise, type:bug) :
    • Dossier v0.15.0 reproduit : clé valide A + jeton signé d'une licence B pour ce poste → « Activée » ; après un 403 license_revoked des cours, le jeton est parti, license.check n'est pas écrit, l'état lit « non activée ».
    • Liste sans ligne pour ce poste, complète → jeton supprimé ; avec une ligne pour ce poste (dérivée ou brute) → gardé.
    • Liste avec une ligne illisible, ids à None, erreur, 429 ou license_revoked → jeton gardé.
    • 403 tardif : le fichier change pendant la requête en vol (match_request de mockito écrit un autre jeton) → le nouveau jeton est gardé.
    • token_dropped vaut not_listed seulement quand la réconciliation a supprimé le jeton.
    • Front : l'état d'activation est relu après le chargement de la liste ; une ligne au Journal à code fixe sur token_dropped et sur license_revoked.
  • Chaque garde prouvée rouge par mutation.

Critères d'acceptation

  • Un dossier dans l'état décrit retrouve « Activer » après un refus des cours, ou après l'ouverture de la liste des machines, sans manipulation de fichier.
  • Une machine désactivée depuis un autre poste retrouve « Activer » à l'ouverture de la liste.
  • Aucun jeton n'est supprimé sur une liste incomplète, une erreur réseau ou un identifiant de poste illisible.
  • L'état révoqué de la clé enregistrée n'est jamais écrit depuis la route des cours.
  • cargo test, npm test et la CI verts.

Complexité estimée

Moyenne.

Ordre

Les trois issues de licence (#373, #370, #367) touchent license_commands.rs, la carte Licence ou l'ADR 0017 : à enchaîner, pas à paralléliser. #367 peut passer en dernier.

Liens

  • #365 (contrôle de révocation, route des cours qui nomme sans écrire), #347 (cause corrigée pour l'avenir), #350 (is_this_machine, ADR 0020), #370 (retrait de la clé)

Depends on

Décisions prises en planification

  • Séance /plan-run du 2026-10-03, revue /review-spec (3 experts).
  • Contrat de la liste : { machines, token_dropped: "not_listed" | null } (écarté : pas de journal sur ce chemin).
  • Suppression conditionnelle sur 403 (le fichier doit valoir le jeton refusé).
  • Compteur de lignes écartées plutôt qu'une erreur, pour ne pas régresser l'affichage de la liste.
  • Helper remove_activation_token_in introduit par #370, réutilisé ici.
  • PR fermante : Fixes #367.

Review caveats

  • 🔴 TECHNIQUE, ARCHITECTURE (résolu) : journal impossible sans contrat de retour → token_dropped.
  • 🟡 SECURITE : 403 tardif pendant une réactivation → suppression conditionnelle (CWE-367).
  • 🟢 SECURITE : la réconciliation suppose que maximus-api#82 rehache avec exactement le dérivé de l'ADR 0020 (HMAC-SHA256 sous com.simpl.resultat/license-machine-id/v1) ; un test Rust avec une ligne au format dérivé attendu le fige côté client.
  • 🟢 SECURITE : lignes de Journal à code fixe (CWE-532).
  • Les numéros de ligne cités datent de main 0e4fef5 et dérivent dans la pile : se fier aux noms de fonctions, intitulés de paragraphes et clés i18n (décision de planification).

Spec source

spec-plan-suites-licence.md (local, ignoré par git)

## Contexte Un dossier de données écrit par la v0.15.0 ou une version antérieure peut contenir une clé de licence valide à côté du jeton d'activation d'une **autre** licence. Trouvé le 2026-10-02 par le plan checker de #365, et vérifié dans le code. Comment l'état se crée : dans la v0.15.0, `store_license` écrit la clé sans toucher à `activation.token` (`git show v0.15.0:src-tauri/src/commands/license_commands.rs`). Saisir une clé, activer la machine, puis enregistrer une autre clé laisse donc le jeton de la première. #347 a corrigé la cause pour l'avenir (`store_license_in` supprime le jeton quand la clé change, `license_commands.rs:234-247`), mais ne répare pas un dossier déjà dans cet état. Le cas concret : un achat suivi d'un remboursement, puis une autre clé enregistrée avec la v0.15.0. Le jeton restant est celui d'une licence révoquée. ## Ce que l'utilisateur voit - La carte Licence affiche « Activée » : `activation_status_in` (L1157) vérifie la signature, l'expiration et l'identifiant de la machine, et ce jeton les satisfait. Le client ne peut pas relier un jeton à une clé : le `sub` du jeton est l'identifiant de la licence, la clé porte le courriel. - Les cours du Bilan sont refusés : 403 `license_revoked` pour la licence du jeton, nommé depuis #365 (`balance_commands.rs:171-181`, `balance.service.ts:2672-2674`). - Aucun moyen de réactiver : « Activer » n'est pas proposé, la machine n'a en général aucune ligne sous la nouvelle clé, et ressaisir la même clé garde le jeton. ## Analyse (`/analyze`, 2026-10-03, `main` `0e4fef5`) **Prémisse confirmée, avec deux nuances sur la piste d'origine** (réconcilier avec la liste des machines) : - L'heuristique « aucune ligne marquée `is_this_machine` → supprimer le jeton » rate le cas où la machine est **aussi** activée sous la clé actuelle : une ligne est marquée, et le jeton de l'autre licence reste. - `machines_from_verify` (L909-929) écarte **en silence** toute ligne qui ne se désérialise pas (`filter_map(... .ok())`, L924 ; un `activated_at` à `null` suffit). Une liste peut donc paraître complète sans l'être. - Le signal le plus direct est le 403 `license_revoked` de la route des cours : le jeton qu'elle présente appartient à une licence révoquée et ne sert plus à rien, quelle que soit la clé enregistrée. **Décision de Max (2026-10-03) : deux signaux, 403 des cours + liste.** 1. **403 `license_revoked` de `/v1/prices` → suppression de `activation.token`.** Aucune écriture de l'état révoqué de la clé : la décision de #365 du 2026-10-02 tient, parce que ce refus juge la licence du jeton, pas la clé. Le jeton part dans tous les cas : si la clé enregistrée est elle-même révoquée, « Activer » le dira (`license_revoked`, enregistré par `note_revocation` sous la clé envoyée) ; sinon, « Activer » répare. La carte repropose « Activer » au prochain chargement de l'état d'activation. 2. **Réconciliation dans `list_machines_in` (L999-1008)**, qui a `dir`, la liste et les identifiants : après une réponse `Ok`, le jeton est supprimé si aucune ligne ne nomme ce poste (`names_this_machine`, formes dérivée **et** brute, ADR 0020), et seulement si toutes ces gardes tiennent : - les identifiants du poste sont lisibles (`ids` vaut `Some`) ; sinon aucune ligne n'est marquée et la suppression serait fausse ; - la liste est complète : aucune ligne écartée par `machines_from_verify`, qui rend désormais aussi le **nombre de lignes écartées** (l'affichage de la liste reste tolérant, pas d'erreur `invalid_response`) ; seule la réconciliation lit ce compteur ; - le jeton se valide localement pour ce poste (sinon il ne compte déjà pas comme activé) ; - aucune suppression sur une erreur, un `license_revoked` ou un `rate_limit_exceeded`. Cette règle couvre aussi une machine désactivée depuis un autre poste, qui garde aujourd'hui son jeton local (la route des cours ne vérifie pas qu'une machine est encore active, suivi côté maximus-api à ouvrir). Côté front, `useLicenseMachines` relit l'état d'activation (`loadActivationStatus`) après `LIST_LOAD_DONE`, pour que « Activer » réapparaisse sans recharger la page. La liste reste chargée à la demande (budget de 5 req/min/IP) : aucune requête ajoutée. ## Travail à faire - [ ] Rust, cours : sur `FetchPriceError::LicenseRevoked`, supprimer `activation.token` **seulement si le fichier vaut encore le jeton présenté dans la requête refusée** (un 403 tardif ne doit pas effacer le jeton neuf d'une réactivation), via `remove_activation_token_in` (helper de #370), best effort. `read_stored_activation_token` passe en version `_in(dir)` et utilise la constante `ACTIVATION_FILE` au lieu du nom en dur. - [ ] Rust, liste : `machines_from_verify` rend aussi le nombre de lignes écartées ; la réconciliation vit dans une fonction dédiée sans `AppHandle` (modèle : `drop_token_of_deactivated_machine_in`) appelée par `list_machines_in` ; **`list_activated_machines` rend `{ machines, token_dropped: "not_listed" | null }`**. - [ ] Front : `licenseService.ts` et `useLicenseMachines.ts` suivent le nouveau contrat ; relire l'état d'activation après le chargement de la liste ; après un refus des cours `license_revoked`, le message invite à réactiver, et le message `auth` (requêtes du même lot parties sans jeton) invite aussi à activer (i18n FR/EN). - [ ] Une ligne au Journal quand un jeton est retiré (`logInfo`), à **code fixe** sur le modèle de `REVOCATION_LOG` (`not_listed_for_this_machine`, `price_refused_revoked`), sans jeton, identifiant de licence ni identifiant de machine (ADR 0020). Côté liste, déclenchée par `token_dropped` ; côté cours, par le code `license_revoked`. - [ ] Docs : `docs/architecture.md:354` (« la route des cours nomme le refus sans rien écrire » devient « supprime le jeton, n'écrit pas l'état de la clé ») ; amendement #365 de l'ADR 0017 (paragraphe « Les autres sources ») ; ADR 0020 si la règle de suppression du jeton y est énumérée ; `CHANGELOG.md` et `CHANGELOG.fr.md` (Fixed/Corrigé). ## Fichiers concernés - `src-tauri/src/commands/balance_commands.rs` : lecture du jeton (L102-111), 403 (L171-181). - `src-tauri/src/commands/license_commands.rs` : `machines_from_verify`, `flag_this_machine` (L978), `list_machines_in`, `activation_status_in`. - `src/hooks/useLicenseMachines.ts`, `src/services/balance.service.ts`, i18n. ## Surface de test - Existants : `activation_status_*` (L1715-1768), `the_machine_list_flags_this_machine_under_either_identifier` (L1936), `deactivation_drops_the_token_that_stood_for_the_freed_slot` (L2002), `verify_answers_map_to_machines_or_codes` (L2850), `verify_revoked_key_is_a_refusal_not_an_empty_list` (L3075), `verify_rate_limit_and_html_outage` (L3089) ; `it_names_a_revoked_license_on_403` (`balance_commands.rs:451`) ; `useLicenseMachines.test.ts`. - À ajouter (régression requise, `type:bug`) : - [ ] Dossier v0.15.0 reproduit : clé valide A + jeton signé d'une licence B pour ce poste → « Activée » ; après un 403 `license_revoked` des cours, le jeton est parti, `license.check` n'est pas écrit, l'état lit « non activée ». - [ ] Liste sans ligne pour ce poste, complète → jeton supprimé ; avec une ligne pour ce poste (dérivée ou brute) → gardé. - [ ] Liste avec une ligne illisible, `ids` à `None`, erreur, 429 ou `license_revoked` → jeton gardé. - [ ] 403 tardif : le fichier change pendant la requête en vol (`match_request` de mockito écrit un autre jeton) → le nouveau jeton est gardé. - [ ] `token_dropped` vaut `not_listed` seulement quand la réconciliation a supprimé le jeton. - [ ] Front : l'état d'activation est relu après le chargement de la liste ; une ligne au Journal à code fixe sur `token_dropped` et sur `license_revoked`. - Chaque garde prouvée rouge par mutation. ## Critères d'acceptation - [ ] Un dossier dans l'état décrit retrouve « Activer » après un refus des cours, ou après l'ouverture de la liste des machines, sans manipulation de fichier. - [ ] Une machine désactivée depuis un autre poste retrouve « Activer » à l'ouverture de la liste. - [ ] Aucun jeton n'est supprimé sur une liste incomplète, une erreur réseau ou un identifiant de poste illisible. - [ ] L'état révoqué de la clé enregistrée n'est jamais écrit depuis la route des cours. - [ ] `cargo test`, `npm test` et la CI verts. ## Complexité estimée Moyenne. ## Ordre Les trois issues de licence (#373, #370, #367) touchent `license_commands.rs`, la carte Licence ou l'ADR 0017 : à enchaîner, pas à paralléliser. #367 peut passer en dernier. ## Liens - #365 (contrôle de révocation, route des cours qui nomme sans écrire), #347 (cause corrigée pour l'avenir), #350 (`is_this_machine`, ADR 0020), #370 (retrait de la clé) ## Depends on - #370 ## Décisions prises en planification - Séance `/plan-run` du 2026-10-03, revue `/review-spec` (3 experts). - Contrat de la liste : `{ machines, token_dropped: "not_listed" | null }` (écarté : pas de journal sur ce chemin). - Suppression conditionnelle sur 403 (le fichier doit valoir le jeton refusé). - Compteur de lignes écartées plutôt qu'une erreur, pour ne pas régresser l'affichage de la liste. - Helper `remove_activation_token_in` introduit par #370, réutilisé ici. - PR fermante : `Fixes #367`. ## Review caveats - 🔴 TECHNIQUE, ARCHITECTURE (résolu) : journal impossible sans contrat de retour → `token_dropped`. - 🟡 SECURITE : 403 tardif pendant une réactivation → suppression conditionnelle (CWE-367). - 🟢 SECURITE : la réconciliation suppose que maximus-api#82 rehache avec exactement le dérivé de l'ADR 0020 (`HMAC-SHA256` sous `com.simpl.resultat/license-machine-id/v1`) ; un test Rust avec une ligne au format dérivé attendu le fige côté client. - 🟢 SECURITE : lignes de Journal à code fixe (CWE-532). - Les numéros de ligne cités datent de `main` `0e4fef5` et dérivent dans la pile : se fier aux noms de fonctions, intitulés de paragraphes et clés i18n (décision de planification). ## Spec source spec-plan-suites-licence.md (local, ignoré par git)
maximus added the
status:triage
type:bug
source:analyste
labels 2026-10-02 18:36:42 +00:00
maximus added
status:ready
and removed
status:triage
labels 2026-10-03 17:53:39 +00:00
maximus added this to the planned-2026-10-03-suites-licence milestone 2026-10-03 19:08:58 +00:00
maximus added
status:in-progress
and removed
status:ready
labels 2026-10-04 21:22:36 +00:00
maximus added
status:review
and removed
status:in-progress
labels 2026-10-04 21:59:49 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-10-05 01:53:46 +00:00
maximus removed the
status:approved
label 2026-10-05 23:23:27 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#367
No description provided.