fix(license): send a derived machine identifier, never the OS machine id (#350) #366
14 changed files with 777 additions and 122 deletions
|
|
@ -31,6 +31,7 @@
|
||||||
### Sécurité
|
### Sécurité
|
||||||
|
|
||||||
- Mise à jour de la dépendance Rust `rustls` (0.23.36 → 0.23.45, qui exige `rustls-webpki` 0.103.13 → 0.103.15 avec elle), corrigeant RUSTSEC-2026-0285. Les messages de poignée de main TLS 1.3 étaient acceptés au mauvais niveau de chiffrement lorsqu'ils suivaient un message de changement de clé dans le même enregistrement — un `EncryptedExtensions` en clair empaqueté avec le `ServerHello`, par exemple — là où la RFC 8446 impose de fermer la connexion. Sévérité moyenne, confidentialité seulement : la transcription de la poignée de main reste authentifiée, donc cela ne permet ni d'altérer ni de compléter une poignée de main depuis une position réseau ; l'effet est qu'un pair peut envoyer en clair ce qui aurait dû être chiffré sans que rustls refuse la connexion. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` — les appels de licence, d'authentification, de cours et de rétroaction de l'application elle-même passent par un autre moteur TLS et n'ont jamais été sur ce chemin. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Le seul comportement qui change est le correctif lui-même : une poignée de main qui enfreint la RFC 8446 §5.1 est désormais fermée au lieu d'être acceptée (#348).
|
- Mise à jour de la dépendance Rust `rustls` (0.23.36 → 0.23.45, qui exige `rustls-webpki` 0.103.13 → 0.103.15 avec elle), corrigeant RUSTSEC-2026-0285. Les messages de poignée de main TLS 1.3 étaient acceptés au mauvais niveau de chiffrement lorsqu'ils suivaient un message de changement de clé dans le même enregistrement — un `EncryptedExtensions` en clair empaqueté avec le `ServerHello`, par exemple — là où la RFC 8446 impose de fermer la connexion. Sévérité moyenne, confidentialité seulement : la transcription de la poignée de main reste authentifiée, donc cela ne permet ni d'altérer ni de compléter une poignée de main depuis une position réseau ; l'effet est qu'un pair peut envoyer en clair ce qui aurait dû être chiffré sans que rustls refuse la connexion. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` — les appels de licence, d'authentification, de cours et de rétroaction de l'application elle-même passent par un autre moteur TLS et n'ont jamais été sur ce chemin. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Le seul comportement qui change est le correctif lui-même : une poignée de main qui enfreint la RFC 8446 §5.1 est désormais fermée au lieu d'être acceptée (#348).
|
||||||
|
- Activer une machine n'envoie plus au serveur de licences l'identifiant machine du système d'exploitation. L'application envoie désormais un identifiant dérivé de celui-ci et propre à Simpl'Résultat, qui ne permet pas de retrouver l'identifiant du système. Une machine activée avec une version antérieure reste activée et toujours marquée « Cette machine » dans la liste ; cette activation antérieure garde l'identifiant avec lequel elle a été faite, jusqu'à ce que vous désactiviez la machine puis l'activiez de nouveau. Ce changement n'efface pas les identifiants que le serveur a déjà reçus (#350).
|
||||||
|
|
||||||
## [0.15.0] - 2026-09-12
|
## [0.15.0] - 2026-09-12
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Updated the Rust dependency `rustls` (0.23.36 → 0.23.45, which requires `rustls-webpki` 0.103.13 → 0.103.15 alongside it), clearing RUSTSEC-2026-0285. TLS 1.3 handshake messages were accepted at the wrong encryption level when they followed a key-changing message inside the same record — a plaintext `EncryptedExtensions` packed together with the `ServerHello`, for instance — where RFC 8446 requires closing the connection instead. Medium severity, confidentiality only: the handshake transcript stays authenticated, so this cannot be used to alter or complete a handshake from a network position; the effect is that a peer can send in the clear what should have been encrypted without rustls refusing the connection. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater` — the application's own licence, authentication, market-price and feedback calls use a different TLS backend and were never on this path. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. The one behaviour that does change is the fix itself: a handshake that breaks RFC 8446 §5.1 is now closed instead of accepted (#348).
|
- Updated the Rust dependency `rustls` (0.23.36 → 0.23.45, which requires `rustls-webpki` 0.103.13 → 0.103.15 alongside it), clearing RUSTSEC-2026-0285. TLS 1.3 handshake messages were accepted at the wrong encryption level when they followed a key-changing message inside the same record — a plaintext `EncryptedExtensions` packed together with the `ServerHello`, for instance — where RFC 8446 requires closing the connection instead. Medium severity, confidentiality only: the handshake transcript stays authenticated, so this cannot be used to alter or complete a handshake from a network position; the effect is that a peer can send in the clear what should have been encrypted without rustls refusing the connection. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater` — the application's own licence, authentication, market-price and feedback calls use a different TLS backend and were never on this path. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. The one behaviour that does change is the fix itself: a handshake that breaks RFC 8446 §5.1 is now closed instead of accepted (#348).
|
||||||
|
- Activating a machine no longer sends the operating system's machine identifier to the license server. The app now sends an identifier derived from it and specific to Simpl'Résultat, from which the system identifier cannot be recovered. A machine activated with an earlier version stays activated and is still marked "This machine" in the list; that earlier activation keeps the identifier it was made with until you deactivate the machine and activate it again. This change does not erase the identifiers the server has already received (#350).
|
||||||
|
|
||||||
## [0.15.0] - 2026-09-12
|
## [0.15.0] - 2026-09-12
|
||||||
|
|
||||||
|
|
|
||||||
105
docs/adr/0020-identifiant-machine-derive.md
Normal file
105
docs/adr/0020-identifiant-machine-derive.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# 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é. 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](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
|
||||||
|
|
@ -305,7 +305,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
|
||||||
|
|
||||||
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
|
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
|
||||||
|
|
||||||
## Commandes Tauri (36)
|
## Commandes Tauri (35)
|
||||||
|
|
||||||
### `fs_commands.rs` — Système de fichiers (6)
|
### `fs_commands.rs` — Système de fichiers (6)
|
||||||
|
|
||||||
|
|
@ -334,23 +334,24 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
|
||||||
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
|
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
|
||||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||||
|
|
||||||
### `license_commands.rs` — Licence et activation machine (10)
|
### `license_commands.rs` — Licence et activation machine (9)
|
||||||
|
|
||||||
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
|
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
|
||||||
- `store_license` — Stockage de la clé dans le répertoire app data
|
- `store_license` — Stockage de la clé dans le répertoire app data
|
||||||
- `store_activation_token` — Stockage du token d'activation
|
- `store_activation_token` — Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machine
|
||||||
- `read_license` — Lecture de la licence stockée
|
- `read_license` — Lecture de la licence stockée
|
||||||
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347)
|
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347)
|
||||||
- `get_machine_id` — Génération d'un identifiant machine unique
|
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270). Envoie l'identifiant machine **dérivé**, jamais celui du système (#350)
|
||||||
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270)
|
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès. Supprime le jeton local quand il représentait la place libérée
|
||||||
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès
|
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust
|
||||||
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide
|
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
|
||||||
- `get_activation_status` — État d'activation de la machine courante. Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
|
|
||||||
|
|
||||||
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ».
|
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ».
|
||||||
|
|
||||||
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`. Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`. Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||||
|
|
||||||
|
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.
|
||||||
|
|
||||||
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
|
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
|
||||||
|
|
||||||
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
|
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
|
||||||
|
|
@ -541,3 +542,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
|
||||||
| [0017](adr/0017-feature-gating-par-tier.md) | Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé | 2026-07-20 | Accepted |
|
| [0017](adr/0017-feature-gating-par-tier.md) | Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé | 2026-07-20 | Accepted |
|
||||||
| [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted |
|
| [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted |
|
||||||
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |
|
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |
|
||||||
|
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,7 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
|
||||||
- **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance »
|
- **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance »
|
||||||
- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible ou n'a pas pu être vérifiée (le plus souvent parce qu'elle a été copiée en partie ou coupée par un retour à la ligne ; une clé complète qui reste refusée n'a pas été émise par le serveur de licences), elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas
|
- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible ou n'a pas pu être vérifiée (le plus souvent parce qu'elle a été copiée en partie ou coupée par un retour à la ligne ; une clé complète qui reste refusée n'a pas été émise par le serveur de licences), elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas
|
||||||
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours
|
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours
|
||||||
|
- **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau
|
||||||
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
|
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
|
||||||
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, la carte l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »
|
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, la carte l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,18 @@
|
||||||
// users whose 2-year token had expired or came from a restored backup (#360). The token now
|
// users whose 2-year token had expired or came from a restored backup (#360). The token now
|
||||||
// only drives the "Activated" state of the License card and the price calls, which the
|
// only drives the "Activated" state of the License card and the price calls, which the
|
||||||
// server verifies (ADR 0017, amendment for #347).
|
// server verifies (ADR 0017, amendment for #347).
|
||||||
|
// - The machine is named to the server by an identifier DERIVED from the operating system's
|
||||||
|
// machine id (HMAC-SHA256 under a fixed application key), never by that id itself (#350,
|
||||||
|
// ADR 0020). A token or a server row from before the derivation carries the raw id: both
|
||||||
|
// forms are recognized locally, only the derived one is ever sent by an activation.
|
||||||
//
|
//
|
||||||
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
|
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
|
||||||
|
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
use jsonwebtoken::errors::ErrorKind;
|
use jsonwebtoken::errors::ErrorKind;
|
||||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
@ -83,10 +89,6 @@ fn license_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
Ok(app_data_dir(app)?.join(LICENSE_FILE))
|
Ok(app_data_dir(app)?.join(LICENSE_FILE))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn activation_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
||||||
Ok(app_data_dir(app)?.join(ACTIVATION_FILE))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip the human-readable prefix and return the bare JWT.
|
/// Strip the human-readable prefix and return the bare JWT.
|
||||||
fn strip_prefix(key: &str) -> Result<&str, LicenseError> {
|
fn strip_prefix(key: &str) -> Result<&str, LicenseError> {
|
||||||
let trimmed = key.trim();
|
let trimmed = key.trim();
|
||||||
|
|
@ -151,19 +153,27 @@ fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInf
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate an activation token against the local machine. The token must be signed by the
|
/// Machine identifier an activation token was issued for, once the token is known to be
|
||||||
/// license server and its `machine_id` claim must match the local machine identifier.
|
/// signed by the license server and unexpired.
|
||||||
fn validate_activation_with_key(
|
fn activation_token_machine_id(token: &str, decoding_key: &DecodingKey) -> Result<String, String> {
|
||||||
token: &str,
|
|
||||||
local_machine_id: &str,
|
|
||||||
decoding_key: &DecodingKey,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let validation = strict_validation();
|
let validation = strict_validation();
|
||||||
|
|
||||||
let data = decode::<ActivationClaims>(token.trim(), decoding_key, &validation)
|
let data = decode::<ActivationClaims>(token.trim(), decoding_key, &validation)
|
||||||
.map_err(|e| format!("Invalid activation token: {}", e))?;
|
.map_err(|e| format!("Invalid activation token: {}", e))?;
|
||||||
|
|
||||||
if data.claims.machine_id != local_machine_id {
|
Ok(data.claims.machine_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an activation token against the local machine. The token must be signed by the
|
||||||
|
/// license server, unexpired, and issued for this machine: under its derived identifier, or
|
||||||
|
/// under the raw one for an activation made before the derivation (ADR 0020, D1).
|
||||||
|
fn validate_activation_with_key(
|
||||||
|
token: &str,
|
||||||
|
ids: &MachineIds,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let machine_id = activation_token_machine_id(token, decoding_key)?;
|
||||||
|
if !ids.names_this_machine(&machine_id) {
|
||||||
return Err("Activation token belongs to a different machine".to_string());
|
return Err("Activation token belongs to a different machine".to_string());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -222,25 +232,38 @@ fn store_license_in(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
||||||
/// caller — it must validate against the local machine_id to be considered active.
|
/// caller — it must have been issued for this machine's derived identifier to be stored.
|
||||||
/// Errors cross the boundary as a serialized [`LicenseError`].
|
/// Errors cross the boundary as a serialized [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
||||||
persist_activation_token(&app, &token).map_err(|e| license_error_to_string(&e))
|
persist_activation_token(&app, &token).map_err(|e| license_error_to_string(&e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate then write the activation token. A token that fails validation came from
|
/// [`store_activation_token_in`] for the app data directory and this machine.
|
||||||
/// the server, hence `InvalidResponse`; a failed write is `Storage`.
|
|
||||||
fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), LicenseError> {
|
fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), LicenseError> {
|
||||||
let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?;
|
let ids = local_machine_ids()?;
|
||||||
let decoding_key = embedded_decoding_key()?;
|
let decoding_key = embedded_decoding_key()?;
|
||||||
validate_activation_with_key(token, &local_id, &decoding_key)
|
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
|
||||||
.map_err(|_| LicenseError::InvalidResponse)?;
|
store_activation_token_in(&dir, token, &ids, &decoding_key)
|
||||||
let path = activation_path(app).map_err(|_| LicenseError::Storage)?;
|
}
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|_| LicenseError::Storage)?;
|
/// Validate then write the activation token the server has just issued. It must carry the
|
||||||
|
/// identifier this version sends, the derived one: the raw one is only accepted from a token
|
||||||
|
/// already on disk (ADR 0020). A token that fails validation came from the server, hence
|
||||||
|
/// `InvalidResponse`; a failed write is `Storage`. Kept apart from `AppHandle` so tests run
|
||||||
|
/// it in a temporary directory.
|
||||||
|
fn store_activation_token_in(
|
||||||
|
dir: &Path,
|
||||||
|
token: &str,
|
||||||
|
ids: &MachineIds,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) -> Result<(), LicenseError> {
|
||||||
|
match activation_token_machine_id(token, decoding_key) {
|
||||||
|
Ok(machine_id) if machine_id == ids.derived => {}
|
||||||
|
_ => return Err(LicenseError::InvalidResponse),
|
||||||
}
|
}
|
||||||
fs::write(&path, token.trim()).map_err(|_| LicenseError::Storage)
|
fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?;
|
||||||
|
fs::write(dir.join(ACTIVATION_FILE), token.trim()).map_err(|_| LicenseError::Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read and validate the stored license. Returns `None` when no license is stored or when the
|
/// Read and validate the stored license. Returns `None` when no license is stored or when the
|
||||||
|
|
@ -375,17 +398,79 @@ fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
|
// === Machine identifier (#350, ADR 0020) ======================================================
|
||||||
/// or hardware migration. The edition does not depend on it (#347, D12): after such a change
|
|
||||||
/// the License card shows this machine as not activated, and "Activate" binds it again on the
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
/// server, which the price calls require.
|
|
||||||
#[tauri::command]
|
/// Fixed, application-specific key of the machine identifier derivation. Not a secret (this
|
||||||
pub fn get_machine_id() -> Result<String, String> {
|
/// repository is public): it makes the identifier specific to this application, as
|
||||||
machine_id_internal()
|
/// machine-id(5) asks. NEVER change it, nor the derivation: the license server records every
|
||||||
|
/// activation under an identifier derived with this key, and another key would orphan them
|
||||||
|
/// all. Two reference vectors pin both in the tests.
|
||||||
|
const MACHINE_ID_KEY: &[u8] = b"com.simpl.resultat/license-machine-id/v1";
|
||||||
|
|
||||||
|
/// Derive the identifier sent to the license server from the operating system's machine id:
|
||||||
|
/// HMAC-SHA256 under [`MACHINE_ID_KEY`], as 64 lowercase hex characters. The OS id is
|
||||||
|
/// confidential and must not be exposed on the network (machine-id(5)); it cannot be
|
||||||
|
/// recovered from the derived one.
|
||||||
|
///
|
||||||
|
/// An empty id is refused, never hashed: every machine without a machine id would otherwise
|
||||||
|
/// share one derived identifier, hence one slot of a license.
|
||||||
|
fn derive_machine_id(raw: &str) -> Result<String, LicenseError> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(LicenseError::Internal);
|
||||||
|
}
|
||||||
|
let mut mac = HmacSha256::new_from_slice(MACHINE_ID_KEY).map_err(|_| LicenseError::Internal)?;
|
||||||
|
mac.update(raw.as_bytes());
|
||||||
|
Ok(mac
|
||||||
|
.finalize()
|
||||||
|
.into_bytes()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn machine_id_internal() -> Result<String, String> {
|
/// The two identifiers the license server may know this machine under. No `Debug` on
|
||||||
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
/// purpose: the raw one must not end up in a log.
|
||||||
|
struct MachineIds {
|
||||||
|
/// Derived identifier: the only one this version sends.
|
||||||
|
derived: String,
|
||||||
|
/// Operating system's machine id, which versions up to 0.15.0 sent as is. Never sent
|
||||||
|
/// again: kept to recognize an activation made by one of those versions (ADR 0020, D1).
|
||||||
|
raw: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MachineIds {
|
||||||
|
/// Both identifiers of the machine whose OS id is `raw`. `Internal` for an empty id.
|
||||||
|
fn from_raw(raw: &str) -> Result<Self, LicenseError> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
Ok(Self {
|
||||||
|
derived: derive_machine_id(raw)?,
|
||||||
|
raw: raw.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `machine_id`, as a server row or a token carries it, names this machine. Both
|
||||||
|
/// forms count (ADR 0020, D3): a row or a token from before the derivation carries the
|
||||||
|
/// raw one, and the server may rehash its rows some day.
|
||||||
|
fn names_this_machine(&self, machine_id: &str) -> bool {
|
||||||
|
machine_id == self.derived || machine_id == self.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operating system's machine id (`/etc/machine-id` on Linux, `MachineGuid` on Windows).
|
||||||
|
/// Stable across reboots; it changes after an OS reinstall or a hardware migration. The
|
||||||
|
/// edition does not depend on it (#347, D12): after such a change the License card shows
|
||||||
|
/// this machine as not activated, and "Activate" binds it again on the server, which the
|
||||||
|
/// price calls require. It never leaves this module as is: see [`derive_machine_id`].
|
||||||
|
fn raw_machine_id() -> Result<String, LicenseError> {
|
||||||
|
machine_uid::get().map_err(|_| LicenseError::Internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifiers of this machine. `Internal` when the OS id is unreadable or empty.
|
||||||
|
fn local_machine_ids() -> Result<MachineIds, LicenseError> {
|
||||||
|
MachineIds::from_raw(&raw_machine_id()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// License server API base URL. Overridable via MAXIMUS_API_URL for development.
|
// License server API base URL. Overridable via MAXIMUS_API_URL for development.
|
||||||
|
|
@ -528,6 +613,24 @@ async fn activate_inner(
|
||||||
.ok_or(LicenseError::InvalidResponse)
|
.ok_or(LicenseError::InvalidResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Activation of the machine whose OS id is `raw_machine_id`, as the command performs it:
|
||||||
|
/// the identifier sent is the derived one, never the raw one (ADR 0020), and the token the
|
||||||
|
/// server issues for it is stored. The derivation sits here, next to the call, so that an
|
||||||
|
/// empty id stops before any request. Kept apart from `AppHandle` so tests run it against
|
||||||
|
/// a mock server and a temporary directory.
|
||||||
|
async fn activate_and_store_in(
|
||||||
|
dir: &Path,
|
||||||
|
api_base: &str,
|
||||||
|
license_key: &str,
|
||||||
|
raw_machine_id: &str,
|
||||||
|
machine_name: Option<&str>,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) -> Result<(), LicenseError> {
|
||||||
|
let ids = MachineIds::from_raw(raw_machine_id)?;
|
||||||
|
let token = activate_inner(api_base, license_key, &ids.derived, machine_name).await?;
|
||||||
|
store_activation_token_in(dir, &token, &ids, decoding_key)
|
||||||
|
}
|
||||||
|
|
||||||
/// Deactivation call (frees the slot of `machine_id`). Returns `NoActiveActivation` as
|
/// Deactivation call (frees the slot of `machine_id`). Returns `NoActiveActivation` as
|
||||||
/// the server says it; [`deactivate_idempotent`] decides what that means for the user.
|
/// the server says it; [`deactivate_idempotent`] decides what that means for the user.
|
||||||
async fn deactivate_inner(
|
async fn deactivate_inner(
|
||||||
|
|
@ -606,25 +709,50 @@ fn read_stored_key(app: &tauri::AppHandle) -> Result<Option<String>, LicenseErro
|
||||||
.map_err(|_| LicenseError::Storage)
|
.map_err(|_| LicenseError::Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Machine info returned by the license server.
|
/// Machine info returned by the license server, plus the local `is_this_machine` flag.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MachineInfo {
|
pub struct MachineInfo {
|
||||||
pub machine_id: String,
|
pub machine_id: String,
|
||||||
pub machine_name: Option<String>,
|
pub machine_name: Option<String>,
|
||||||
pub activated_at: String,
|
pub activated_at: String,
|
||||||
pub last_seen_at: String,
|
pub last_seen_at: String,
|
||||||
|
/// Whether the row names this machine. Set locally by [`flag_this_machine`], never read
|
||||||
|
/// from the server's answer.
|
||||||
|
#[serde(skip_deserializing)]
|
||||||
|
pub is_this_machine: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activation status for display in the UI.
|
/// Activation status for display in the UI. It carries no machine identifier: the webview
|
||||||
|
/// gets the flagged machine list instead (ADR 0020).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ActivationStatus {
|
pub struct ActivationStatus {
|
||||||
pub is_activated: bool,
|
pub is_activated: bool,
|
||||||
pub machine_id: String,
|
}
|
||||||
|
|
||||||
|
/// Flag the rows that name this machine, under either identifier (ADR 0020, D3). `ids` is
|
||||||
|
/// `None` when the OS id is unreadable: no row is flagged then, and the list still loads.
|
||||||
|
fn flag_this_machine(mut machines: Vec<MachineInfo>, ids: Option<&MachineIds>) -> Vec<MachineInfo> {
|
||||||
|
for machine in &mut machines {
|
||||||
|
machine.is_this_machine =
|
||||||
|
ids.is_some_and(|ids| ids.names_this_machine(&machine.machine_id));
|
||||||
|
}
|
||||||
|
machines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machines activated for the key, as the command returns them: the server's list with
|
||||||
|
/// this machine flagged. Kept apart from `AppHandle` so tests run it against a mock server.
|
||||||
|
async fn list_machines_inner(
|
||||||
|
api_base: &str,
|
||||||
|
license_key: &str,
|
||||||
|
ids: Option<&MachineIds>,
|
||||||
|
) -> Result<Vec<MachineInfo>, LicenseError> {
|
||||||
|
let machines = verify_inner(api_base, license_key).await?;
|
||||||
|
Ok(flag_this_machine(machines, ids))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activate this machine with the license server. Reads the stored license key, sends
|
/// Activate this machine with the license server. Reads the stored license key, sends
|
||||||
/// the machine_id to the API, and stores the returned activation token. Errors cross
|
/// the derived machine identifier to the API, and stores the returned activation token.
|
||||||
/// the boundary as a serialized [`LicenseError`].
|
/// Errors cross the boundary as a serialized [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
activate_machine_impl(&app)
|
activate_machine_impl(&app)
|
||||||
|
|
@ -634,17 +762,19 @@ pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
|
||||||
async fn activate_machine_impl(app: &tauri::AppHandle) -> Result<(), LicenseError> {
|
async fn activate_machine_impl(app: &tauri::AppHandle) -> Result<(), LicenseError> {
|
||||||
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?;
|
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?;
|
||||||
let machine_id = machine_id_internal().map_err(|_| LicenseError::Internal)?;
|
let os_machine_id = raw_machine_id()?;
|
||||||
let machine_name = hostname::get().ok().and_then(|h| h.into_string().ok());
|
let machine_name = hostname::get().ok().and_then(|h| h.into_string().ok());
|
||||||
let token = activate_inner(
|
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
|
||||||
|
let decoding_key = embedded_decoding_key()?;
|
||||||
|
activate_and_store_in(
|
||||||
|
&dir,
|
||||||
&api_base_url(),
|
&api_base_url(),
|
||||||
&license_key,
|
&license_key,
|
||||||
&machine_id,
|
&os_machine_id,
|
||||||
machine_name.as_deref(),
|
machine_name.as_deref(),
|
||||||
|
&decoding_key,
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
// Validates the token against the local machine_id before writing it.
|
|
||||||
persist_activation_token(app, &token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deactivate a machine on the license server, freeing a slot. Idempotent: a machine the
|
/// Deactivate a machine on the license server, freeing a slot. Idempotent: a machine the
|
||||||
|
|
@ -664,18 +794,48 @@ async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Re
|
||||||
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?;
|
let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?;
|
||||||
deactivate_idempotent(&api_base_url(), &license_key, machine_id).await?;
|
deactivate_idempotent(&api_base_url(), &license_key, machine_id).await?;
|
||||||
|
|
||||||
// If this machine was deactivated, remove the local activation token
|
// The slot is free on the server: drop the local token if it stood for it. Best effort
|
||||||
let local_id = machine_id_internal().map_err(|_| LicenseError::Internal)?;
|
// on the OS id: the deactivation itself has succeeded.
|
||||||
if machine_id == local_id {
|
let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?;
|
||||||
let act_path = activation_path(app).map_err(|_| LicenseError::Storage)?;
|
if let Ok(decoding_key) = embedded_decoding_key() {
|
||||||
if act_path.exists() {
|
let ids = local_machine_ids().ok();
|
||||||
let _ = fs::remove_file(&act_path);
|
drop_token_of_deactivated_machine_in(&dir, machine_id, ids.as_ref(), &decoding_key);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Identifier the stored activation token was issued for, when that token is signed by the
|
||||||
|
/// license server and unexpired. `None` for a missing, unreadable, expired or forged token.
|
||||||
|
fn stored_token_machine_id(dir: &Path, decoding_key: &DecodingKey) -> Option<String> {
|
||||||
|
let token = fs::read_to_string(dir.join(ACTIVATION_FILE)).ok()?;
|
||||||
|
activation_token_machine_id(&token, decoding_key).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the server has freed the slot of `deactivated`, drop the local activation token if
|
||||||
|
/// it stood for that slot (ADR 0020). `ids` is `None` when the OS id is unreadable. Kept
|
||||||
|
/// apart from `AppHandle` so tests run it in a temporary directory.
|
||||||
|
fn drop_token_of_deactivated_machine_in(
|
||||||
|
dir: &Path,
|
||||||
|
deactivated: &str,
|
||||||
|
ids: Option<&MachineIds>,
|
||||||
|
decoding_key: &DecodingKey,
|
||||||
|
) {
|
||||||
|
let token_id = stored_token_machine_id(dir, decoding_key);
|
||||||
|
let stood_for_it = match ids {
|
||||||
|
// A row of this machine: the token goes with it. One exception: the old raw row is
|
||||||
|
// deactivated while the token stands for the derived one, which stays active.
|
||||||
|
Some(ids) if ids.names_this_machine(deactivated) => {
|
||||||
|
!(deactivated == ids.raw && token_id.as_deref() == Some(ids.derived.as_str()))
|
||||||
|
}
|
||||||
|
// Another machine's row, or an unreadable OS id: only a token issued for that row.
|
||||||
|
_ => token_id.as_deref() == Some(deactivated),
|
||||||
|
};
|
||||||
|
if stood_for_it {
|
||||||
|
let _ = fs::remove_file(dir.join(ACTIVATION_FILE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// List all machines currently activated for the stored license (empty when no key is
|
/// List all machines currently activated for the stored license (empty when no key is
|
||||||
/// stored). Errors cross the boundary as a serialized [`LicenseError`].
|
/// stored). Errors cross the boundary as a serialized [`LicenseError`].
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -689,36 +849,36 @@ async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result<Vec<Mach
|
||||||
let Some(license_key) = read_stored_key(app)? else {
|
let Some(license_key) = read_stored_key(app)? else {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
};
|
};
|
||||||
verify_inner(&api_base_url(), &license_key).await
|
// Best effort on the OS id: unreadable, it leaves every row unflagged.
|
||||||
|
let ids = local_machine_ids().ok();
|
||||||
|
list_machines_inner(&api_base_url(), &license_key, ids.as_ref()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the local activation status without contacting the server. Strict on purpose: an
|
/// Check the local activation status without contacting the server. Strict on purpose: an
|
||||||
/// expired token, or one issued for another machine, reads as not activated, so the License
|
/// expired token, or one issued for another machine, reads as not activated, so the License
|
||||||
/// card offers "Activate" (the price calls need a token the server accepts). The edition does
|
/// card offers "Activate" (the price calls need a token the server accepts). A machine whose
|
||||||
/// not depend on this status (#347, D12).
|
/// OS id is unreadable cannot be matched to a token and reads as not activated too. The
|
||||||
|
/// edition does not depend on this status (#347, D12).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
|
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
|
||||||
let machine_id = machine_id_internal()?;
|
|
||||||
let dir = app_data_dir(&app)?;
|
let dir = app_data_dir(&app)?;
|
||||||
let is_activated = match embedded_decoding_key() {
|
let is_activated = match (local_machine_ids(), embedded_decoding_key()) {
|
||||||
Ok(decoding_key) => activation_status_in(&dir, &machine_id, &decoding_key),
|
(Ok(ids), Ok(decoding_key)) => activation_status_in(&dir, &ids, &decoding_key),
|
||||||
Err(_) => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
Ok(ActivationStatus {
|
Ok(ActivationStatus { is_activated })
|
||||||
is_activated,
|
|
||||||
machine_id,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `dir/activation.token` activates this machine: signed by the license server,
|
/// Whether `dir/activation.token` activates this machine: signed by the license server,
|
||||||
/// issued for `local_machine_id` and not expired. A missing, unreadable, expired or foreign
|
/// not expired, and issued for one of this machine's identifiers (the raw one for a token
|
||||||
/// token reads as not activated, which is what brings back "Activate" (#360). Kept apart
|
/// from before the derivation, ADR 0020). A missing, unreadable, expired or foreign token
|
||||||
/// from `AppHandle` so tests run it in a temporary directory.
|
/// reads as not activated, which is what brings back "Activate" (#360). Kept apart from
|
||||||
fn activation_status_in(dir: &Path, local_machine_id: &str, decoding_key: &DecodingKey) -> bool {
|
/// `AppHandle` so tests run it in a temporary directory.
|
||||||
|
fn activation_status_in(dir: &Path, ids: &MachineIds, decoding_key: &DecodingKey) -> bool {
|
||||||
let Ok(token) = fs::read_to_string(dir.join(ACTIVATION_FILE)) else {
|
let Ok(token) = fs::read_to_string(dir.join(ACTIVATION_FILE)) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
validate_activation_with_key(&token, local_machine_id, decoding_key).is_ok()
|
validate_activation_with_key(&token, ids, decoding_key).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Tests ====================================================================================
|
// === Tests ====================================================================================
|
||||||
|
|
@ -936,44 +1096,59 @@ mod tests {
|
||||||
assert_eq!(result.unwrap_err(), LicenseError::UnknownEdition);
|
assert_eq!(result.unwrap_err(), LicenseError::UnknownEdition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The machine the tests run "on", and another one: an OS id and the identifier derived
|
||||||
|
// from it. These pairs are the two reference vectors of ADR 0020, written out as literals
|
||||||
|
// so that no test can pass by comparing the derivation to itself.
|
||||||
|
const THIS_RAW: &str = "0123456789abcdef0123456789abcdef";
|
||||||
|
const THIS_DERIVED: &str = "4f8817b37072a3a1e6ef014f34c46835ba715b6f2f97afee1fc680d7fc170db1";
|
||||||
|
const OTHER_RAW: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||||
|
const OTHER_DERIVED: &str = "3b9579ec36b3daf37a2aaab0abe2315fa4a9f07200ae568c5aaa614663037d9d";
|
||||||
|
|
||||||
|
fn this_machine() -> MachineIds {
|
||||||
|
MachineIds::from_raw(THIS_RAW).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_token_matches_machine() {
|
fn activation_token_matches_machine() {
|
||||||
let (enc, dec) = default_keys();
|
let (enc, dec) = default_keys();
|
||||||
let claims = ActivationClaims {
|
let token = activation_for(&enc, THIS_DERIVED);
|
||||||
sub: "license-id".to_string(),
|
assert!(validate_activation_with_key(&token, &this_machine(), &dec).is_ok());
|
||||||
iat: now(),
|
}
|
||||||
exp: now() + 86400,
|
|
||||||
machine_id: "this-machine".to_string(),
|
#[test]
|
||||||
};
|
fn activation_token_from_before_the_derivation_still_matches() {
|
||||||
let token = make_token(&enc, &claims);
|
// ADR 0020, D1: versions up to 0.15.0 sent the OS id as is, and their token carries
|
||||||
assert!(validate_activation_with_key(&token, "this-machine", &dec).is_ok());
|
// it. A machine activated by one of them stays activated.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let token = activation_for(&enc, THIS_RAW);
|
||||||
|
assert!(validate_activation_with_key(&token, &this_machine(), &dec).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_token_rejects_other_machine() {
|
fn activation_token_rejects_other_machine() {
|
||||||
let (enc, dec) = default_keys();
|
let (enc, dec) = default_keys();
|
||||||
let claims = ActivationClaims {
|
for other in [OTHER_RAW, OTHER_DERIVED] {
|
||||||
sub: "license-id".to_string(),
|
let token = activation_for(&enc, other);
|
||||||
iat: now(),
|
let result = validate_activation_with_key(&token, &this_machine(), &dec);
|
||||||
exp: now() + 86400,
|
assert!(result.is_err(), "copied activation token must be rejected ({})", other);
|
||||||
machine_id: "machine-A".to_string(),
|
}
|
||||||
};
|
|
||||||
let token = make_token(&enc, &claims);
|
|
||||||
let result = validate_activation_with_key(&token, "machine-B", &dec);
|
|
||||||
assert!(result.is_err(), "copied activation token must be rejected");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_token_rejects_expired() {
|
fn activation_token_rejects_expired() {
|
||||||
// The token stays strictly validated where it decides something: the "Activated"
|
// The token stays strictly validated where it decides something: the "Activated"
|
||||||
// state of the card and the token written after an activation (#347, D12).
|
// state of the card and the token written after an activation (#347, D12). The raw
|
||||||
|
// form gets no leniency.
|
||||||
let (enc, dec) = default_keys();
|
let (enc, dec) = default_keys();
|
||||||
let token = expired_activation_for(&enc, "this-machine");
|
for id in [THIS_DERIVED, THIS_RAW] {
|
||||||
|
let token = expired_activation_for(&enc, id);
|
||||||
assert!(
|
assert!(
|
||||||
validate_activation_with_key(&token, "this-machine", &dec).is_err(),
|
validate_activation_with_key(&token, &this_machine(), &dec).is_err(),
|
||||||
"an expired token must not read as activated"
|
"an expired token must not read as activated ({})",
|
||||||
|
id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedded_public_key_pem_parses() {
|
fn embedded_public_key_pem_parses() {
|
||||||
|
|
@ -1245,31 +1420,44 @@ mod tests {
|
||||||
fn activation_status_accepts_a_token_of_this_machine() {
|
fn activation_status_accepts_a_token_of_this_machine() {
|
||||||
let (enc, dec) = default_keys();
|
let (enc, dec) = default_keys();
|
||||||
let dir = TestDir::new("status_this_machine");
|
let dir = TestDir::new("status_this_machine");
|
||||||
dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine"));
|
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED));
|
||||||
assert!(activation_status_in(&dir.0, "this-machine", &dec));
|
assert!(activation_status_in(&dir.0, &this_machine(), &dec));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activation_status_accepts_a_token_from_before_the_derivation() {
|
||||||
|
// ADR 0020, D1: updating the app must not turn "Activated" into "Activate".
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("status_raw_token");
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_RAW));
|
||||||
|
assert!(activation_status_in(&dir.0, &this_machine(), &dec));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_status_refuses_a_missing_token() {
|
fn activation_status_refuses_a_missing_token() {
|
||||||
let (_enc, dec) = default_keys();
|
let (_enc, dec) = default_keys();
|
||||||
let dir = TestDir::new("status_missing");
|
let dir = TestDir::new("status_missing");
|
||||||
assert!(!activation_status_in(&dir.0, "this-machine", &dec));
|
assert!(!activation_status_in(&dir.0, &this_machine(), &dec));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_status_refuses_an_expired_token() {
|
fn activation_status_refuses_an_expired_token() {
|
||||||
let (enc, dec) = default_keys();
|
let (enc, dec) = default_keys();
|
||||||
|
for id in [THIS_DERIVED, THIS_RAW] {
|
||||||
let dir = TestDir::new("status_expired");
|
let dir = TestDir::new("status_expired");
|
||||||
dir.write(ACTIVATION_FILE, expired_activation_for(&enc, "this-machine"));
|
dir.write(ACTIVATION_FILE, expired_activation_for(&enc, id));
|
||||||
assert!(!activation_status_in(&dir.0, "this-machine", &dec));
|
assert!(!activation_status_in(&dir.0, &this_machine(), &dec), "claim {}", id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activation_status_refuses_a_token_of_another_machine() {
|
fn activation_status_refuses_a_token_of_another_machine() {
|
||||||
let (enc, dec) = default_keys();
|
let (enc, dec) = default_keys();
|
||||||
|
for other in [OTHER_RAW, OTHER_DERIVED] {
|
||||||
let dir = TestDir::new("status_other_machine");
|
let dir = TestDir::new("status_other_machine");
|
||||||
dir.write(ACTIVATION_FILE, activation_for(&enc, "another-machine"));
|
dir.write(ACTIVATION_FILE, activation_for(&enc, other));
|
||||||
assert!(!activation_status_in(&dir.0, "this-machine", &dec));
|
assert!(!activation_status_in(&dir.0, &this_machine(), &dec), "claim {}", other);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1277,8 +1465,8 @@ mod tests {
|
||||||
let (_enc, dec) = default_keys();
|
let (_enc, dec) = default_keys();
|
||||||
let (other_enc, _other_dec) = test_keys([7u8; 32]);
|
let (other_enc, _other_dec) = test_keys([7u8; 32]);
|
||||||
let dir = TestDir::new("status_other_key");
|
let dir = TestDir::new("status_other_key");
|
||||||
dir.write(ACTIVATION_FILE, activation_for(&other_enc, "this-machine"));
|
dir.write(ACTIVATION_FILE, activation_for(&other_enc, THIS_DERIVED));
|
||||||
assert!(!activation_status_in(&dir.0, "this-machine", &dec));
|
assert!(!activation_status_in(&dir.0, &this_machine(), &dec));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1286,7 +1474,320 @@ mod tests {
|
||||||
let (_enc, dec) = default_keys();
|
let (_enc, dec) = default_keys();
|
||||||
let dir = TestDir::new("status_unreadable");
|
let dir = TestDir::new("status_unreadable");
|
||||||
dir.write(ACTIVATION_FILE, NOT_UTF8);
|
dir.write(ACTIVATION_FILE, NOT_UTF8);
|
||||||
assert!(!activation_status_in(&dir.0, "this-machine", &dec));
|
assert!(!activation_status_in(&dir.0, &this_machine(), &dec));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Machine identifier: derivation and transition (#350, ADR 0020) ========================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derivation_matches_the_reference_vectors() {
|
||||||
|
// Frozen: the license server records every activation under these identifiers, and
|
||||||
|
// maximus-api reuses the same vectors. A red test here means the key or the formula
|
||||||
|
// moved, and every existing activation with it.
|
||||||
|
assert_eq!(derive_machine_id(THIS_RAW), Ok(THIS_DERIVED.to_string()));
|
||||||
|
assert_eq!(derive_machine_id(OTHER_RAW), Ok(OTHER_DERIVED.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derived_identifier_is_64_lowercase_hex_and_ignores_surrounding_whitespace() {
|
||||||
|
let derived = derive_machine_id(" 0123456789abcdef0123456789abcdef\n").unwrap();
|
||||||
|
assert_eq!(derived, THIS_DERIVED, "the OS id is trimmed before the derivation");
|
||||||
|
assert_eq!(derived.len(), 64);
|
||||||
|
assert!(derived.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_os_id_is_refused_not_hashed() {
|
||||||
|
// Hashed, it would give every machine without a machine id the same identifier.
|
||||||
|
for raw in ["", " ", "\n"] {
|
||||||
|
assert_eq!(derive_machine_id(raw), Err(LicenseError::Internal), "raw {:?}", raw);
|
||||||
|
assert_eq!(
|
||||||
|
MachineIds::from_raw(raw).err(),
|
||||||
|
Some(LicenseError::Internal),
|
||||||
|
"raw {:?}",
|
||||||
|
raw
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn machine_ids_hold_both_forms_and_name_only_this_machine() {
|
||||||
|
let ids = MachineIds::from_raw(&format!(" {}\n", THIS_RAW)).unwrap();
|
||||||
|
assert_eq!(ids.derived, THIS_DERIVED);
|
||||||
|
assert_eq!(ids.raw, THIS_RAW);
|
||||||
|
assert!(ids.names_this_machine(THIS_DERIVED));
|
||||||
|
assert!(ids.names_this_machine(THIS_RAW));
|
||||||
|
assert!(!ids.names_this_machine(OTHER_RAW));
|
||||||
|
assert!(!ids.names_this_machine(OTHER_DERIVED));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_token_must_carry_the_derived_identifier() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("fresh_token_derived");
|
||||||
|
let token = activation_for(&enc, THIS_DERIVED);
|
||||||
|
store_activation_token_in(&dir.0, &format!(" {}\n", token), &this_machine(), &dec).unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_token_for_any_other_identifier_is_refused_and_not_written() {
|
||||||
|
// The raw id is accepted from a token already on disk only: the server answers a
|
||||||
|
// request that carried the derived one.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
for id in [THIS_RAW, OTHER_RAW, OTHER_DERIVED] {
|
||||||
|
let dir = TestDir::new("fresh_token_refused");
|
||||||
|
let result =
|
||||||
|
store_activation_token_in(&dir.0, &activation_for(&enc, id), &this_machine(), &dec);
|
||||||
|
assert_eq!(result, Err(LicenseError::InvalidResponse), "claim {}", id);
|
||||||
|
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "claim {}", id);
|
||||||
|
}
|
||||||
|
let dir = TestDir::new("fresh_token_expired");
|
||||||
|
let expired = expired_activation_for(&enc, THIS_DERIVED);
|
||||||
|
assert_eq!(
|
||||||
|
store_activation_token_in(&dir.0, &expired, &this_machine(), &dec),
|
||||||
|
Err(LicenseError::InvalidResponse)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body of an activation answer carrying `token`.
|
||||||
|
fn activation_answer(token: &str) -> String {
|
||||||
|
serde_json::json!({ "activation_token": token }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activation_sends_the_derived_identifier_never_the_raw_one() {
|
||||||
|
// The decision point of #350. The only mock declared wants the derived identifier in
|
||||||
|
// `machine_id` and refuses any body where the OS id appears, in whatever field: a
|
||||||
|
// request carrying it gets mockito's 501 and this test fails.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("activate_sends_derived");
|
||||||
|
let token = activation_for(&enc, THIS_DERIVED);
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let derived_only = server
|
||||||
|
.mock("POST", "/v1/licenses/activate")
|
||||||
|
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
|
||||||
|
"product": "simpl-resultat",
|
||||||
|
"license_key": "SR-BASE-k",
|
||||||
|
"machine_id": THIS_DERIVED,
|
||||||
|
"machine_name": "laptop",
|
||||||
|
})))
|
||||||
|
.match_request(|request| {
|
||||||
|
!request.utf8_lossy_body().map(|body| body.contains(THIS_RAW)).unwrap_or(true)
|
||||||
|
})
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(activation_answer(&token))
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", THIS_RAW, Some("laptop"), &dec)
|
||||||
|
.await
|
||||||
|
.expect("activation");
|
||||||
|
|
||||||
|
derived_only.assert_async().await;
|
||||||
|
assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_empty_os_id_stops_the_activation_before_any_request() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
for raw in ["", " \n"] {
|
||||||
|
let dir = TestDir::new("activate_empty_id");
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let any_request = server
|
||||||
|
.mock("POST", mockito::Matcher::Any)
|
||||||
|
.expect(0)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
let result =
|
||||||
|
activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", raw, None, &dec).await;
|
||||||
|
assert_eq!(result, Err(LicenseError::Internal), "raw {:?}", raw);
|
||||||
|
any_request.assert_async().await;
|
||||||
|
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "raw {:?}", raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activation_refuses_a_token_issued_for_another_identifier() {
|
||||||
|
// Here the OS id echoed back, as a server answering for a version up to 0.15.0 would.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("activate_wrong_token");
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let _m = server
|
||||||
|
.mock("POST", "/v1/licenses/activate")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(activation_answer(&activation_for(&enc, THIS_RAW)))
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
let result =
|
||||||
|
activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", THIS_RAW, None, &dec).await;
|
||||||
|
assert_eq!(result, Err(LicenseError::InvalidResponse));
|
||||||
|
assert!(!dir.0.join(ACTIVATION_FILE).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of the machine list as the server sends it.
|
||||||
|
fn listed_machine(machine_id: &str) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"machine_name": "laptop",
|
||||||
|
"activated_at": "2026-09-01T00:00:00.000Z",
|
||||||
|
"last_seen_at": "2026-09-20T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_machine_list_flags_this_machine_under_either_identifier() {
|
||||||
|
// ADR 0020, D3: a row activated before the derivation carries the OS id, and the
|
||||||
|
// server may rehash its rows some day. The flag is never read from the answer: the
|
||||||
|
// foreign row below claims it and loses it.
|
||||||
|
let mut foreign = listed_machine(OTHER_RAW);
|
||||||
|
foreign["is_this_machine"] = serde_json::Value::Bool(true);
|
||||||
|
let answer = serde_json::json!({
|
||||||
|
"valid": true,
|
||||||
|
"machine_limit": 3,
|
||||||
|
"machines": [listed_machine(THIS_DERIVED), listed_machine(THIS_RAW), foreign,
|
||||||
|
listed_machine(OTHER_DERIVED)],
|
||||||
|
});
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let listing = server
|
||||||
|
.mock("POST", "/v1/licenses/verify")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(answer.to_string())
|
||||||
|
.expect(2)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
let flags = |machines: Vec<MachineInfo>| -> Vec<(String, bool)> {
|
||||||
|
machines.into_iter().map(|m| (m.machine_id, m.is_this_machine)).collect()
|
||||||
|
};
|
||||||
|
let expected = |this: bool| {
|
||||||
|
vec![
|
||||||
|
(THIS_DERIVED.to_string(), this),
|
||||||
|
(THIS_RAW.to_string(), this),
|
||||||
|
(OTHER_RAW.to_string(), false),
|
||||||
|
(OTHER_DERIVED.to_string(), false),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let ids = this_machine();
|
||||||
|
let listed = list_machines_inner(&server.url(), "SR-BASE-k", Some(&ids)).await.unwrap();
|
||||||
|
assert_eq!(flags(listed), expected(true));
|
||||||
|
|
||||||
|
// Unreadable OS id: the list still loads, and no row is flagged.
|
||||||
|
let unflagged = list_machines_inner(&server.url(), "SR-BASE-k", None).await.unwrap();
|
||||||
|
assert_eq!(flags(unflagged), expected(false));
|
||||||
|
listing.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_webview_gets_the_flag_and_no_identifier_of_its_own() {
|
||||||
|
// The wire shape `licenseService.ts` reads: a flagged row carries `is_this_machine`,
|
||||||
|
// and the activation status is the single field `is_activated`.
|
||||||
|
let row: MachineInfo = serde_json::from_value(listed_machine(THIS_RAW)).unwrap();
|
||||||
|
let flagged = flag_this_machine(vec![row], Some(&this_machine()));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&flagged[0]).unwrap(),
|
||||||
|
serde_json::json!({
|
||||||
|
"machine_id": THIS_RAW,
|
||||||
|
"machine_name": "laptop",
|
||||||
|
"activated_at": "2026-09-01T00:00:00.000Z",
|
||||||
|
"last_seen_at": "2026-09-20T00:00:00.000Z",
|
||||||
|
"is_this_machine": true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(ActivationStatus { is_activated: true }).unwrap(),
|
||||||
|
serde_json::json!({ "is_activated": true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deactivation_drops_the_token_that_stood_for_the_freed_slot() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let ids = this_machine();
|
||||||
|
// (identifier of the stored token, identifier deactivated, token kept, case)
|
||||||
|
let cases = [
|
||||||
|
(THIS_RAW, THIS_RAW, false, "raw token, raw row of this machine"),
|
||||||
|
(THIS_DERIVED, THIS_DERIVED, false, "derived token, derived row of this machine"),
|
||||||
|
(THIS_DERIVED, THIS_RAW, true, "derived token, old raw row of this machine"),
|
||||||
|
(THIS_RAW, THIS_DERIVED, false, "raw token, row rehashed by the server"),
|
||||||
|
(THIS_RAW, OTHER_RAW, true, "raw token, row of another machine"),
|
||||||
|
(THIS_DERIVED, OTHER_DERIVED, true, "derived token, row of another machine"),
|
||||||
|
(THIS_DERIVED, OTHER_RAW, true, "derived token, raw row of another machine"),
|
||||||
|
(OTHER_RAW, OTHER_RAW, false, "token of another machine, that machine's row"),
|
||||||
|
(OTHER_RAW, OTHER_DERIVED, true, "token of another machine, a different row"),
|
||||||
|
// Data restored from another computer (#360): the foreign token goes with any
|
||||||
|
// row of this machine, as it did before the derivation.
|
||||||
|
(OTHER_RAW, THIS_RAW, false, "token of another machine, raw row of this machine"),
|
||||||
|
(OTHER_DERIVED, THIS_DERIVED, false, "token of another machine, derived row of this machine"),
|
||||||
|
];
|
||||||
|
for (token_id, deactivated, kept, case) in cases {
|
||||||
|
let dir = TestDir::new("deactivation_token");
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, token_id));
|
||||||
|
drop_token_of_deactivated_machine_in(&dir.0, deactivated, Some(&ids), &dec);
|
||||||
|
assert_eq!(dir.0.join(ACTIVATION_FILE).exists(), kept, "{}", case);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deactivating_this_machine_drops_a_token_that_no_longer_validates() {
|
||||||
|
// Expired, forged or unreadable, the file stands for no other activation: it goes
|
||||||
|
// with this machine's row, as it did before the derivation.
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let (other_enc, _other_dec) = test_keys([7u8; 32]);
|
||||||
|
let ids = this_machine();
|
||||||
|
let stale: Vec<(Vec<u8>, &str)> = vec![
|
||||||
|
(expired_activation_for(&enc, THIS_RAW).into_bytes(), "expired raw token"),
|
||||||
|
(expired_activation_for(&enc, THIS_DERIVED).into_bytes(), "expired derived token"),
|
||||||
|
(activation_for(&other_enc, THIS_DERIVED).into_bytes(), "token signed by another key"),
|
||||||
|
(NOT_UTF8.to_vec(), "unreadable file"),
|
||||||
|
];
|
||||||
|
for (contents, case) in stale {
|
||||||
|
for deactivated in [THIS_RAW, THIS_DERIVED] {
|
||||||
|
let dir = TestDir::new("deactivation_stale_token");
|
||||||
|
dir.write(ACTIVATION_FILE, &contents);
|
||||||
|
drop_token_of_deactivated_machine_in(&dir.0, deactivated, Some(&ids), &dec);
|
||||||
|
assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{}, row {}", case, deactivated);
|
||||||
|
}
|
||||||
|
// Another machine's row never touches it.
|
||||||
|
let dir = TestDir::new("deactivation_stale_token");
|
||||||
|
dir.write(ACTIVATION_FILE, &contents);
|
||||||
|
drop_token_of_deactivated_machine_in(&dir.0, OTHER_RAW, Some(&ids), &dec);
|
||||||
|
assert!(dir.0.join(ACTIVATION_FILE).exists(), "{}, row of another machine", case);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_a_readable_os_id_only_the_token_s_own_identifier_decides() {
|
||||||
|
let (enc, dec) = default_keys();
|
||||||
|
let cases = [
|
||||||
|
(THIS_RAW, THIS_RAW, false),
|
||||||
|
(THIS_DERIVED, THIS_DERIVED, false),
|
||||||
|
(THIS_RAW, THIS_DERIVED, true),
|
||||||
|
(THIS_DERIVED, OTHER_RAW, true),
|
||||||
|
];
|
||||||
|
for (token_id, deactivated, kept) in cases {
|
||||||
|
let dir = TestDir::new("deactivation_no_os_id");
|
||||||
|
dir.write(ACTIVATION_FILE, activation_for(&enc, token_id));
|
||||||
|
drop_token_of_deactivated_machine_in(&dir.0, deactivated, None, &dec);
|
||||||
|
assert_eq!(
|
||||||
|
dir.0.join(ACTIVATION_FILE).exists(),
|
||||||
|
kept,
|
||||||
|
"token {}, row {}",
|
||||||
|
token_id,
|
||||||
|
deactivated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deactivation_without_a_stored_token_writes_nothing() {
|
||||||
|
let (_enc, dec) = default_keys();
|
||||||
|
let dir = TestDir::new("deactivation_no_token");
|
||||||
|
drop_token_of_deactivated_machine_in(&dir.0, THIS_DERIVED, Some(&this_machine()), &dec);
|
||||||
|
assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Dev override =========================================================================
|
// === Dev override =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -456,7 +456,6 @@ pub fn run() {
|
||||||
commands::store_activation_token,
|
commands::store_activation_token,
|
||||||
commands::read_license,
|
commands::read_license,
|
||||||
commands::get_edition,
|
commands::get_edition,
|
||||||
commands::get_machine_id,
|
|
||||||
commands::check_entitlement,
|
commands::check_entitlement,
|
||||||
commands::activate_machine,
|
commands::activate_machine,
|
||||||
commands::deactivate_machine,
|
commands::deactivate_machine,
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,12 @@ const nowS = () => Math.floor(Date.now() / 1000);
|
||||||
// Noon UTC, so the calendar day is the same in UTC (CI) and America/Toronto.
|
// Noon UTC, so the calendar day is the same in UTC (CI) and America/Toronto.
|
||||||
const TEN_YEAR_EXPIRY_S = Date.UTC(2036, 8, 26, 12) / 1000;
|
const TEN_YEAR_EXPIRY_S = Date.UTC(2036, 8, 26, 12) / 1000;
|
||||||
|
|
||||||
const machine = (id: string): MachineInfo => ({
|
const machine = (id: string, isThisMachine = false): MachineInfo => ({
|
||||||
machine_id: id,
|
machine_id: id,
|
||||||
machine_name: `host-${id}`,
|
machine_name: `host-${id}`,
|
||||||
activated_at: "2026-09-01T12:00:00Z",
|
activated_at: "2026-09-01T12:00:00Z",
|
||||||
last_seen_at: "2026-09-20T12:00:00Z",
|
last_seen_at: "2026-09-20T12:00:00Z",
|
||||||
|
is_this_machine: isThisMachine,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** What the real hook returns for `state`, with its real selectors. */
|
/** What the real hook returns for `state`, with its real selectors. */
|
||||||
|
|
@ -68,7 +69,7 @@ function hookView(partial: Partial<LicenseMachinesState>, revoked = false) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const notActivated = { is_activated: false, machine_id: "this" };
|
const notActivated = { is_activated: false };
|
||||||
const loadedList = (list: MachineInfo[]) => ({
|
const loadedList = (list: MachineInfo[]) => ({
|
||||||
listOpen: true,
|
listOpen: true,
|
||||||
listLoaded: true,
|
listLoaded: true,
|
||||||
|
|
@ -142,7 +143,7 @@ describe("LicenseCard revoked key", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the Activated badge: the server deactivated every machine of a revoked key", () => {
|
it("hides the Activated badge: the server deactivated every machine of a revoked key", () => {
|
||||||
const activated = { is_activated: true, machine_id: "this" };
|
const activated = { is_activated: true };
|
||||||
machines = hookView({ activation: activated }, true);
|
machines = hookView({ activation: activated }, true);
|
||||||
expect(render()).not.toContain("license.machines.activated");
|
expect(render()).not.toContain("license.machines.activated");
|
||||||
|
|
||||||
|
|
@ -185,6 +186,40 @@ describe("LicenseCard machines", () => {
|
||||||
expect(render()).not.toContain("license.machines.count");
|
expect(render()).not.toContain("license.machines.count");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The backend decides which rows name this machine (#350): it knows both forms of the
|
||||||
|
// identifier, the webview knows neither.
|
||||||
|
const THIS_MACHINE = "(license.machines.thisMachine)";
|
||||||
|
/** The row of `id`: from its host name up to its Deactivate button. */
|
||||||
|
const rowOf = (html: string, id: string) => {
|
||||||
|
const start = html.indexOf(`host-${id}`);
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
return html.slice(start, html.indexOf("license.machines.deactivate", start));
|
||||||
|
};
|
||||||
|
|
||||||
|
it("marks the row the backend flags as this machine, and only that row", () => {
|
||||||
|
machines = hookView(loadedList([machine("a"), machine("b", true), machine("c")]));
|
||||||
|
const html = render();
|
||||||
|
expect(rowOf(html, "b")).toContain(THIS_MACHINE);
|
||||||
|
expect(rowOf(html, "a")).not.toContain(THIS_MACHINE);
|
||||||
|
expect(rowOf(html, "c")).not.toContain(THIS_MACHINE);
|
||||||
|
expect(html.split(THIS_MACHINE)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks every flagged row: an old activation of this machine next to its new one", () => {
|
||||||
|
machines = hookView(loadedList([machine("old", true), machine("new", true), machine("c")]));
|
||||||
|
expect(render().split(THIS_MACHINE)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks no row when none is flagged, whatever the activation status", () => {
|
||||||
|
for (const activation of [null, notActivated, { is_activated: true }]) {
|
||||||
|
machines = hookView({ ...loadedList([machine("a"), machine("b")]), activation });
|
||||||
|
expect(render()).not.toContain(THIS_MACHINE);
|
||||||
|
}
|
||||||
|
// And the other way round: a flagged row is marked without any activation status.
|
||||||
|
machines = hookView({ ...loadedList([machine("a", true)]), activation: null });
|
||||||
|
expect(render()).toContain(THIS_MACHINE);
|
||||||
|
});
|
||||||
|
|
||||||
it("lets an empty list say so on its own line", () => {
|
it("lets an empty list say so on its own line", () => {
|
||||||
machines = hookView(loadedList([]));
|
machines = hookView(loadedList([]));
|
||||||
const html = render();
|
const html = render();
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ function LicenseCardBody() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{machines.machines.map((m) => {
|
{machines.machines.map((m) => {
|
||||||
const isThis = machines.activation?.machine_id === m.machine_id;
|
const isThis = m.is_this_machine;
|
||||||
const deactivating = machines.deactivatingId === m.machine_id;
|
const deactivating = machines.deactivatingId === m.machine_id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const machine = (id: string): MachineInfo => ({
|
||||||
machine_name: `host-${id}`,
|
machine_name: `host-${id}`,
|
||||||
activated_at: "2026-09-01T00:00:00Z",
|
activated_at: "2026-09-01T00:00:00Z",
|
||||||
last_seen_at: "2026-09-20T00:00:00Z",
|
last_seen_at: "2026-09-20T00:00:00Z",
|
||||||
|
is_this_machine: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reduce = (
|
const reduce = (
|
||||||
|
|
@ -117,7 +118,7 @@ describe("licenseMachinesReducer — who may clear the activation error", () =>
|
||||||
// Only ACTIVATE_START and a DEACTIVATE_DONE after a machine-limit refusal
|
// Only ACTIVATE_START and a DEACTIVATE_DONE after a machine-limit refusal
|
||||||
// clear it. Everything else (list loads in particular) must leave it alone.
|
// clear it. Everything else (list loads in particular) must leave it alone.
|
||||||
const preserving: LicenseMachinesAction[] = [
|
const preserving: LicenseMachinesAction[] = [
|
||||||
{ type: "ACTIVATION_STATUS", activation: { is_activated: false, machine_id: "x" } },
|
{ type: "ACTIVATION_STATUS", activation: { is_activated: false } },
|
||||||
{ type: "TOGGLE_LIST" },
|
{ type: "TOGGLE_LIST" },
|
||||||
{ type: "TOGGLE_LIST" },
|
{ type: "TOGGLE_LIST" },
|
||||||
{ type: "LIST_LOAD_START" },
|
{ type: "LIST_LOAD_START" },
|
||||||
|
|
@ -300,7 +301,7 @@ function harness(overrides: Partial<LicenseMachinesDeps> = {}) {
|
||||||
listActivatedMachines: vi.fn(
|
listActivatedMachines: vi.fn(
|
||||||
async (): Promise<LicenseResult<MachineInfo[]>> => ({ ok: true, value: [] }),
|
async (): Promise<LicenseResult<MachineInfo[]>> => ({ ok: true, value: [] }),
|
||||||
),
|
),
|
||||||
getActivationStatus: vi.fn(async () => ({ is_activated: true, machine_id: "this" })),
|
getActivationStatus: vi.fn(async () => ({ is_activated: true })),
|
||||||
refresh: vi.fn(async () => {}),
|
refresh: vi.fn(async () => {}),
|
||||||
markRevoked: vi.fn(),
|
markRevoked: vi.fn(),
|
||||||
dispatch: (action) => {
|
dispatch: (action) => {
|
||||||
|
|
@ -401,8 +402,8 @@ describe("performDeactivate", () => {
|
||||||
it("re-reads the local status, so deactivating this machine brings back Activate", async () => {
|
it("re-reads the local status, so deactivating this machine brings back Activate", async () => {
|
||||||
const getActivationStatus = vi
|
const getActivationStatus = vi
|
||||||
.fn<() => Promise<ActivationStatus>>()
|
.fn<() => Promise<ActivationStatus>>()
|
||||||
.mockResolvedValueOnce({ is_activated: true, machine_id: "this" })
|
.mockResolvedValueOnce({ is_activated: true })
|
||||||
.mockResolvedValueOnce({ is_activated: false, machine_id: "this" });
|
.mockResolvedValueOnce({ is_activated: false });
|
||||||
const { deps, getState } = harness({ getActivationStatus });
|
const { deps, getState } = harness({ getActivationStatus });
|
||||||
await loadActivationStatus(deps);
|
await loadActivationStatus(deps);
|
||||||
await loadMachines({
|
await loadMachines({
|
||||||
|
|
|
||||||
|
|
@ -1124,6 +1124,7 @@
|
||||||
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
|
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
|
||||||
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",
|
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",
|
||||||
"Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer, a different key or an expired activation, the card shows \"Not activated\": click \"Activate\"",
|
"Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer, a different key or an expired activation, the card shows \"Not activated\": click \"Activate\"",
|
||||||
|
"Activation sends the license server your key, this computer's name and a derived identifier, specific to Simpl'Résultat, in place of the system's machine identifier",
|
||||||
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot",
|
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot",
|
||||||
"If the license server reports that your key was revoked, the License card says so until the app is closed and no longer offers \"Activate\""
|
"If the license server reports that your key was revoked, the License card says so until the app is closed and no longer offers \"Activate\""
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1124,6 +1124,7 @@
|
||||||
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
|
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
|
||||||
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",
|
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",
|
||||||
"Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »",
|
"Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »",
|
||||||
|
"L'activation envoie au serveur de licences votre clé, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système",
|
||||||
"La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place",
|
"La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place",
|
||||||
"Si le serveur de licences signale que votre clé a été révoquée, la carte Licence l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »"
|
"Si le serveur de licences signale que votre clé a été révoquée, la carte Licence l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ describe("license command wrappers", () => {
|
||||||
machine_name: "laptop",
|
machine_name: "laptop",
|
||||||
activated_at: "2026-09-01T00:00:00Z",
|
activated_at: "2026-09-01T00:00:00Z",
|
||||||
last_seen_at: "2026-09-20T00:00:00Z",
|
last_seen_at: "2026-09-20T00:00:00Z",
|
||||||
|
is_this_machine: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
mockInvoke.mockResolvedValueOnce(machines);
|
mockInvoke.mockResolvedValueOnce(machines);
|
||||||
|
|
|
||||||
|
|
@ -27,24 +27,30 @@ export async function getEdition(): Promise<Edition> {
|
||||||
return invoke<Edition>("get_edition");
|
return invoke<Edition>("get_edition");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMachineId(): Promise<string> {
|
|
||||||
return invoke<string>("get_machine_id");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkEntitlement(feature: string): Promise<boolean> {
|
export async function checkEntitlement(feature: string): Promise<boolean> {
|
||||||
return invoke<boolean>("check_entitlement", { feature });
|
return invoke<boolean>("check_entitlement", { feature });
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MachineInfo {
|
export interface MachineInfo {
|
||||||
|
/**
|
||||||
|
* Identifier of the row on the license server: derived (64 hex characters) for an
|
||||||
|
* activation made since #350, the operating system's machine id for an older one.
|
||||||
|
*/
|
||||||
machine_id: string;
|
machine_id: string;
|
||||||
machine_name: string | null;
|
machine_name: string | null;
|
||||||
activated_at: string;
|
activated_at: string;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
|
/**
|
||||||
|
* Whether the row names this machine. Decided in Rust, which knows both forms of the
|
||||||
|
* identifier: no command reads the local one for the webview (ADR 0020). A row from
|
||||||
|
* before the derivation still carries it in `machine_id`, until the machine is
|
||||||
|
* activated again.
|
||||||
|
*/
|
||||||
|
is_this_machine: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivationStatus {
|
export interface ActivationStatus {
|
||||||
is_activated: boolean;
|
is_activated: boolean;
|
||||||
machine_id: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue