Simpl-Resultat/docs/adr/0020-identifiant-machine-derive.md
le king fu ed35570121
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m21s
fix(license): drop an activation token that no longer stands for this machine
A data directory written by v0.15.0 or earlier can hold a valid key next
to the activation token of another license, typically a refunded hence
revoked one. The License card read "Activated", market prices were
refused, and "Activate" was never offered: the folder could not recover
without a manual file removal.

- Prices: a 403 license_revoked drops the refused token, and only while
  the file still holds it, so an activation that lands while the refused
  request is in flight keeps its new token (CWE-367). The key's revoked
  state is never written from this route. The token is read through
  ACTIVATION_FILE by the directory-based read_stored_activation_token_in,
  in fetch_price_in, testable without AppHandle.
- Machine list: list_activated_machines returns { machines,
  token_dropped }. A complete list that names this machine under neither
  identifier drops a token that activates this machine, when the OS id is
  readable and the token is the one on disk when the list was asked for.
  An error, a revoked key or a rate limit drops nothing.
  machines_from_verify counts the rows it skips, which the list still
  tolerates. This also gives "Activate" back to a machine deactivated from
  another computer.
- Front: the activation status is read again after every list load; a
  fixed log line (ACTIVATION_TOKEN_LOG) is written on token_dropped and on
  a price refused as revoked; the revoked-license price message invites to
  activate the machine again.
- Docs: architecture, ADR 0017 (#365 amendment), ADR 0020 (rule 4), user
  guide and in-app tips, changelogs.

Resolves #367

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:58:25 -04:00

14 KiB

ADR 0020 — Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences

  • Status: Accepted
  • Date: 2026-10-01
  • Issues: #350 (cette décision), maximus/maximus-api#82 (identifiants bruts déjà stockés par le serveur), maximus/la-compagnie-maximus#184 (données d'activation absentes de la politique de confidentialité), #367 (jeton qui ne représente plus aucune activation, règle 4)
  • S'appuie sur l'ADR 0017, amendement #347 (l'édition suit la clé seule), et sur l'ADR 0009 (cours du marché via maximus-api)

Contexte

Activer une machine la lie à une clé de licence auprès du serveur (POST /v1/licenses/activate). Le serveur compte les machines contre la limite de la clé et émet un jeton d'activation, que la route des cours exige. Pour reconnaître une machine, le client envoyait jusqu'à la v0.15.0 l'identifiant du système tel quel : machine_uid::get(), soit le contenu de /var/lib/dbus/machine-id ou de /etc/machine-id sous Linux (32 caractères hexadécimaux), et la valeur MachineGuid du registre sous Windows (36 caractères).

Trois constats, relevés par la revue du 2026-09-24 et revérifiés le 2026-10-01 :

  • machine-id(5) tient cet identifiant pour confidentiel. Il ne doit pas être exposé sur le réseau, et une application qui a besoin d'un identifiant stable lié à la machine doit le hacher avec une fonction à clé, sous une clé fixe qui lui est propre. D'autres logiciels lisent le même identifiant : l'envoyer tel quel permet de recouper une machine d'un service à l'autre.
  • Le serveur le garde et le rediffuse. maximus-api le stocke avec le courriel de la licence et le nom de l'ordinateur (license_activations), le renvoie dans chaque liste de machines et le signe dans le jeton d'activation, valable deux ans.
  • La vente est ouverte depuis le 2026-09-29. Chaque activation faite par une version qui envoie l'identifiant brut en laisse un de plus sur le serveur. La spec d'origine prévoyait pourtant un « hash unique de la machine ».

Un fait encadre la transition : depuis #347, l'édition ne dépend plus du jeton d'activation. Un jeton que le client refuse coûte l'état « Activée » de la carte Licence et les cours du Bilan, jamais l'édition.

Décision

Le client n'envoie plus au serveur que hex(HMAC-SHA256(clé, identifiant du système)), et reste juste pour une machine activée avant ce changement. Cinq règles la tiennent.

1. Une dérivation figée

derive_machine_id (src-tauri/src/commands/license_commands.rs) calcule HMAC-SHA256 de l'identifiant du système, rogné de ses blancs, sous la clé ASCII com.simpl.resultat/license-machine-id/v1, et rend le résultat en 64 caractères hexadécimaux minuscules.

  • La clé n'est pas un secret (le dépôt est public). Elle rend l'identifiant propre à l'application : le même poste donne un autre identifiant à tout autre logiciel qui suivrait la même recette, et l'identifiant du système ne se retrouve pas depuis son dérivé. Elle ne protège pas du sens inverse : qui détient déjà l'identifiant du système d'un poste peut recalculer son dérivé, puisque la clé est publique. La protection porte sur la remontée du dérivé vers l'identifiant du système, pas sur ce rapprochement.
  • Ni la clé ni la formule ne changent, jamais. Chaque activation est enregistrée par le serveur sous un identifiant dérivé de cette clé : en changer rendrait orphelines toutes les activations. Deux vecteurs de référence les épinglent, côté client aujourd'hui et côté serveur le jour où il en aura besoin (maximus/maximus-api#82) :
Identifiant du système Identifiant dérivé
0123456789abcdef0123456789abcdef 4f8817b37072a3a1e6ef014f34c46835ba715b6f2f97afee1fc680d7fc170db1
a1b2c3d4-e5f6-7890-abcd-ef1234567890 3b9579ec36b3daf37a2aaab0abe2315fa4a9f07200ae568c5aaa614663037d9d
  • Un identifiant vide est refusé, pas haché. Toutes les machines sans machine-id partageraient sinon le même dérivé, donc la même place sur une licence.
  • Les deux formes ne se confondent pas : 64 caractères pour un dérivé, 32 ou 36 pour un identifiant brut. Une base peut donc être auditée à la longueur.

2. Seule l'activation envoie un identifiant de cette machine

  • activate_machine envoie le dérivé. Le jeton que le serveur vient d'émettre doit porter ce même dérivé, sinon la réponse est refusée (invalid_response) et rien n'est écrit.
  • deactivate_machine renvoie au serveur l'identifiant de la ligne choisie dans la liste, c'est-à-dire une valeur que le serveur a lui-même fournie. Le client ne calcule rien pour cet appel.
  • Plus aucune commande ne lit l'identifiant du système pour le remettre au webview : get_machine_id, qui n'avait pas d'appelant, est retirée, et ActivationStatus ne porte plus d'identifiant. La liste que rend le serveur porte encore celui d'une ligne antérieure à la dérivation, marquée is_this_machine quand c'est cette machine, jusqu'à sa réactivation.

3. Une machine déjà activée garde son activation (D1)

Un jeton valide dont le claim machine_id vaut l'identifiant brut de cette machine se lit « Activée », comme un jeton qui porte le dérivé. La carte, la liste et les cours d'une machine activée avant ce changement restent donc tels quels, sans appel réseau ajouté. Un jeton expiré, signé par une autre clé ou émis pour une autre machine, sous l'une ou l'autre forme, reste refusé.

4. Le client ne suppose rien du serveur (D3)

  • « Cette machine » se décide côté Rust. list_activated_machines pose is_this_machine sur chaque ligne dont l'identifiant vaut le dérivé ou le brut de cette machine. Le client reste juste que le serveur garde ses anciennes lignes telles quelles ou les rehache un jour en place.
  • La suppression du jeton local suit la place qu'il représente. Après une désactivation réussie, drop_token_of_deactivated_machine_in supprime activation.token dans deux cas. La ligne désactivée est une ligne de cette machine, sous l'une ou l'autre forme : le jeton part avec elle, sauf quand c'est l'ancienne ligne brute et que le jeton vaut pour la ligne dérivée, restée active. La ligne désactivée est celle d'une autre machine : le jeton ne part que s'il a été émis pour cette ligne-là (un jeton repris d'un autre poste).
Jeton stocké Identifiant désactivé Jeton après
brut brut de cette machine supprimé
dérivé dérivé de cette machine supprimé
dérivé brut de cette machine (ancienne ligne restée active) gardé
brut dérivé de cette machine (ligne rehachée par le serveur) supprimé
expiré, illisible ou signé par une autre clé une ligne de cette machine supprimé
valide, émis pour une autre machine une ligne de cette machine supprimé
de cette machine ligne d'une autre machine gardé
  • Un identifiant du système illisible ne bloque ni la liste ni la désactivation. Aucune ligne n'est alors marquée, et le jeton ne part que s'il a été émis pour la ligne désactivée. L'état d'activation se lit « Non activée », et l'activation échoue avant tout appel.
  • Un jeton qui ne représente plus aucune activation part aussi (#367, 2026-10-04). À l'ouverture de la liste des machines, drop_token_of_unlisted_machine_in supprime le jeton quand aucune ligne de la clé enregistrée ne nomme ce poste, sous l'une ou l'autre forme : jeton d'une autre licence laissé par la v0.15.0, place libérée depuis un autre poste. Rien ne part sans identifiant du système lisible, sur une liste incomplète (une ligne illisible pourrait être celle de ce poste), pour un jeton qui n'active pas ce poste, ni quand le jeton a changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Le refus license_revoked de la route des cours supprime le jeton qu'il a refusé, à la même condition. Une ligne que le serveur rehacherait (maximus/maximus-api#82) n'est reconnue que si elle vaut exactement le dérivé de la règle 1 : sous une autre forme, une machine activée en brut perdrait son jeton à l'ouverture de la liste, et sa réactivation prendrait une seconde place. Un test l'épingle côté client avec le vecteur de référence.

5. Ce que la transition laisse en place (D2)

  • Un jeton antérieur garde l'identifiant brut dans son claim. Il est envoyé en Bearer à chaque appel de cours, donc pour une licence Premium seulement, jusqu'à ce que la machine soit réactivée (« Désactiver » puis « Activer »).
  • Les lignes que le serveur a déjà reçues gardent leur identifiant brut, y compris après une désactivation, qui marque la ligne inactive sans la supprimer. Le client ne peut rien y faire : c'est l'objet de maximus/maximus-api#82.
  • Une seconde ligne à la réactivation. Quand une ligne brute de cette machine est encore active et que le jeton local manque ou n'est plus valide (expiration au bout de deux ans, dossier de données réinitialisé, autre clé puis retour à la première), « Activer » envoie le dérivé. Le serveur n'a aucune ligne active sous cet identifiant : c'est pour lui une activation nouvelle, là où la v0.15.0 obtenait un rafraîchissement. Elle prend une place de plus, ou rend machine_limit_reached si la licence est pleine. Les deux lignes sont alors marquées « Cette machine », et désactiver l'ancienne garde le jeton (règle 4). Ce reste s'éteint si le serveur rehache en place ses lignes actives avant l'expiration des premiers jetons bruts, deux ans après leur émission (maximus/maximus-api#82).
  • Le critère « aucun identifiant brut ne quitte le poste » est donc borné aux activations faites avec une version qui dérive.

Condition de retrait de la compatibilité

L'acceptation de l'identifiant brut, dans le jeton comme dans la liste, se retire quand deux conditions sont réunies :

  1. deux ans se sont écoulés depuis la dernière émission d'un jeton à claim brut. Le serveur signe les jetons pour deux ans : passé ce délai, plus aucun jeton brut n'est valide. Cette date ne se déduit pas d'un numéro de version. Une v0.15.0 en édition Gratuite ne se met plus à jour ; son utilisateur peut acheter une clé des mois plus tard, la saisir et cliquer « Activer » avant toute mise à jour, et le serveur signe alors un jeton brut neuf. Seul le serveur connaît la date : aujourd'hui le last_seen_at le plus récent de ses lignes brutes, que seuls l'activation et son rafraîchissement écrivent. S'il hache un jour les identifiants à l'arrivée, il devra la consigner à part (maximus/maximus-api#82) ;
  2. le serveur ne renvoie plus de ligne brute (maximus/maximus-api#82).

Tant que l'une des deux manque, retirer la compatibilité ferait perdre à une machine son état « Activée » ou son marqueur « Cette machine ».

Alternatives considérées

  • Coupure nette : refuser les jetons bruts. Rejeté : la machine s'afficherait « Non activée », sa réactivation créerait une seconde ligne, et l'ancienne resterait dans la liste comme une machine fantôme qui occupe une place sur la licence. Les cours tomberaient jusqu'à la réactivation.
  • Réactivation automatique au démarrage. Rejeté : deux appels réseau que l'utilisateur n'a pas demandés, à faire tenir dans la limite de cinq requêtes par minute que partagent les routes de licence, et un état bancal si le second échoue.
  • Hacher côté serveur seulement. Rejeté comme réponse unique : l'identifiant brut continuerait de quitter le poste, ce que machine-id(5) demande d'éviter. C'est en revanche le complément attendu du serveur pour les versions antérieures (maximus/maximus-api#82).
  • sd_id128_get_machine_app_specific() de systemd. Rejeté : même principe, mais l'appel n'existe que sous Linux et l'application est aussi livrée sous Windows. Une seule dérivation, écrite dans le client, vaut pour les deux.
  • Un identifiant d'installation tiré au hasard. Rejeté : il ne serait plus lié à la machine. Copier le dossier de données copierait l'activation, et une réinstallation consommerait une place de plus.

Conséquences

  • Positif. Une activation faite par une version qui dérive ne montre plus au serveur qu'un identifiant propre à Simpl'Résultat. Le serveur n'a rien à changer : l'identifiant reste pour lui une chaîne opaque comparée par égalité, et 64 caractères passent son schéma.
  • Positif. La dérivation et sa clé ont un seul point de vérité, tenu par deux vecteurs de référence que le serveur peut reprendre tels quels.
  • Coût. Une branche de compatibilité vit dans le client jusqu'à la condition de retrait ci-dessus.
  • Coût. Les identifiants bruts déjà reçus restent sur le serveur tant que maximus/maximus-api#82 n'est pas traitée, et un jeton antérieur continue de porter le sien.
  • À surveiller. Le dérivé dépend de ce que machine_uid::get() renvoie. Les vecteurs épinglent la dérivation, pas la lecture : une version de la crate machine-uid qui changerait la forme renvoyée (casse, séparateurs) changerait le dérivé de chaque machine. C'était déjà vrai de l'identifiant brut ; relire ce point avant de monter la crate.
  • Portée. Le nom de l'ordinateur continue d'être envoyé en clair : il sert à reconnaître ses machines dans la liste. La déclaration de ces données relève de maximus/la-compagnie-maximus#184.

Liens

  • src-tauri/src/commands/license_commands.rs — derive_machine_id, MachineIds, les règles de jeton, de liste et de désactivation, et leurs tests
  • src/components/settings/LicenseCard.tsx — marqueur « Cette machine » lu depuis is_this_machine
  • ADR 0017 — amendement #347 : l'édition suit la clé seule, le jeton ne sert qu'à l'état « Activée » et aux cours
  • docs/architecture.md — section license_commands.rs
  • Issues #350, maximus/maximus-api#82, maximus/la-compagnie-maximus#184