Simpl-Resultat/docs/adr/0020-identifiant-machine-derive.md
le king fu e5662ad018
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 9m19s
fix(license): send a derived machine identifier, never the OS machine id (#350)
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.

- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
  characters, pinned by two reference vectors. An empty OS id is refused,
  never hashed.
- Activation sends the derived identifier and requires the issued token to
  carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
  whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
  identifier (D3). `ActivationStatus` no longer carries an identifier and
  the unused `get_machine_id` command is removed: no command hands the OS
  id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
  freed slot: any row of this machine takes it, except the old raw row
  while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
  removing the raw-id compatibility.

Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.

No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.

Refs #350

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:52:48 -04:00

103 lines
11 KiB
Markdown

# 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](0017-feature-gating-par-tier.md), amendement #347 (l'édition suit la clé seule), et sur l'[ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) (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é.
- **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 remet l'identifiant du système au webview : `get_machine_id`, qui n'avait pas d'appelant, est retirée, et `ActivationStatus` ne porte plus d'identifiant.
### 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é |
| 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.
- 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 version qui envoyait l'identifiant brut (v0.15.0) : le serveur signe les jetons pour deux ans, plus aucun jeton brut n'est alors valide ;
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](0017-feature-gating-par-tier.md) — 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