Simpl-Resultat/docs/adr/0020-identifiant-machine-derive.md
le king fu 0a9c9f71dd
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m47s
PR Check — Rust / rust (pull_request) Successful in 8m46s
fix(license): apply the review of #366
The review approved the previous head with five suggestions; this commit
applies the four that touch the repository (the fifth is the manual check
in the pull request description).

- Tests: a valid token issued for another machine goes with any row of
  this machine. Two rows added to the deactivation table: the reviewer's
  mutation that kept such a token stayed green before them.
- ADR 0020, removal condition: counted from the last raw-claim token the
  server issued, not from a client version. A Free 0.15.0 no longer
  updates and can still activate with the raw id months from now.
- ADR 0020: names what the transition leaves behind when a machine is
  activated again while its raw row is still active (a second row, one
  more slot), and what a public key does not cover (whoever already holds
  the OS id can recompute the derived one).
- "No command hands the OS id to the webview" was wider than the code: the
  server's list still carries it for a row from before the derivation.
  Bounded in the ADR, architecture.md and the licenseService.ts comment.

No behaviour change. Rust 168, vitest 1314.

Refs #350

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:12:32 -04:00

13 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é)
  • 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.

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