# ADR-0006 : Stockage des tokens OAuth dans le trousseau OS - **Date** : 2026-04-14 - **Statut** : Accepté ## Contexte Depuis la v0.7.0, Simpl'Résultat utilise OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (`access_token`, `refresh_token`, `id_token`) étaient persistés dans `/auth/tokens.json`, protégés uniquement par les permissions fichier (`0600` sous Unix, aucune ACL sous Windows). Le refresh token donne une session longue durée. Le laisser en clair expose l'utilisateur à plusieurs classes d'attaques : - Malware local tournant sous le même UID (lecture du home directory). - Backups automatiques (home sync, backup tools) qui copient le fichier sans distinction. - Shell non-root obtenu par l'attaquant. - Sous Windows, absence de protection ACL rendait le fichier lisible par n'importe quel process utilisateur. De plus, avant cette ADR, `account.json` était également en clair et son champ `subscription_status` servait au gating licence (Premium). Écrire manuellement `{"subscription_status": "active"}` dans ce fichier contournait le paywall. ## Options considérées ### Option 1 — Trousseau OS via `keyring` crate (RETENUE) Librairie Rust `keyring` (v3.6) qui expose une API unifiée au-dessus des trousseaux natifs : - **Windows** : Credential Manager (Win32 API, toujours présent) - **Linux** : Secret Service via D-Bus (gnome-keyring, kwallet, keepassxc) - **macOS** : Keychain Services (hors cible actuelle) **Avantages** : - Le système d'exploitation gère la clé maître (session utilisateur). - Pas de mot de passe supplémentaire à demander à l'utilisateur. - Support multi-plateforme natif. **Inconvénients** : - Sur Linux, requiert un service Secret Service actif (D-Bus + keyring daemon). Sur une session headless ou un CI sans D-Bus, il faut un fallback. - Dépendance de build supplémentaire (`libdbus-1-dev`). ### Option 2 — `tauri-plugin-stronghold` Chiffrement au repos avec une master password déverrouillée au démarrage. **Rejeté** parce que : - Casse l'UX de connexion silencieuse (refresh automatique au démarrage). - Ajoute une saisie de passphrase à chaque lancement. - Surface de UX plus large qu'un simple fallback keychain. ### Option 3 — Chiffrement AES-256-GCM custom avec clé dérivée du PIN **Rejeté** parce que : - Seulement applicable aux profils protégés par PIN (minorité). - Les tokens OAuth doivent être lus sans interaction (refresh silencieux), donc aucune clé à demander. - Simplement déplace le problème de la clé maître. ## Décision **Trousseau OS via `keyring` crate, avec fallback fichier supervisé.** ### Architecture Nouveau module `src-tauri/src/commands/token_store.rs` qui centralise le stockage : - `save(app, &StoredTokens)` — tente le keychain, retombe sur `tokens.json` chiffré par permissions (`0600` Unix) si keychain indisponible. - `load(app) -> Option` — lit depuis le keychain, migre un `tokens.json` résiduel à la volée. - `delete(app)` — efface les deux stores (idempotent). - `store_mode(app) -> Option` — exposé au frontend pour afficher une bannière quand le fallback est actif. ### Choix du backend `keyring` Le crate `keyring` v3 demande la sélection explicite des backends : - **Linux** : `sync-secret-service` + `crypto-rust` → passe par le crate `dbus-secret-service` → crate `dbus` → lib C `libdbus-1`. Requiert `libdbus-1-dev` au build time et `libdbus-1-3` au runtime (ce dernier est universellement présent sur les distributions desktop Linux). - **Windows** : `windows-native` → bind direct sur `windows-sys`, pas de dépendance externe. L'option `async-secret-service` (via `zbus`, pur Rust) a été envisagée pour éviter la dépendance `libdbus-1-dev`, mais elle force une API asynchrone sur toutes les plateformes, ce qui ne matche pas le design sync de `license_commands::current_edition` (appelé depuis `check_entitlement`, sync). Le compromis accepté : un paquet apt de plus dans la CI, une API sync partout. ### Identité du trousseau `service = "com.simpl.resultat"` (identifiant canonique de l'app dans `tauri.conf.json`), `user = "oauth-tokens"`. Ce choix aligne l'entrée keychain avec l'identité installée de l'app pour que les outils de management de credentials du système puissent la scoper correctement. ### Garde anti-downgrade Un flag persistant `store_mode` (valeurs `keychain` ou `file`) est écrit dans `/auth/store_mode` après chaque opération. Une fois qu'un `store_mode = keychain` a été enregistré, toute tentative ultérieure de sauvegarde qui échoue sur le keychain retourne une erreur au lieu de silenter-dégrader vers le fichier. Cela empêche un attaquant local de forcer la dégradation en bloquant temporairement D-Bus pour capturer les tokens en clair au prochain refresh. ### Migration transparente Au premier `load()` après upgrade depuis v0.7.x, le module détecte `tokens.json` résiduel, copie son contenu dans le keychain, puis **overwrite le fichier avec des zéros + `fsync()` avant `remove_file()`**. C'est un mitigation best-effort contre la récupération des bits sur unallocated sectors (CWE-212). Pas un substitut à un disk encryption : les backups antérieurs à la migration conservent évidemment le vieux fichier. Documenté dans le CHANGELOG comme recommandation de rotation de session post-upgrade pour les utilisateurs inquiets. ### Scope : `tokens.json` migré, `account.json` signé `account.json` **n'est pas** migré dans le keychain pour limiter le blast radius du changement et garder `write_restricted()` en place pour les fichiers non-sensibles. Toutefois, le champ `subscription_status` de ce cache servait au gating de licence Premium, ce qui créait un trou de tampering : un malware local pouvait écrire `"active"` dans le cache pour bypass le paywall sans jamais toucher le keychain. **Corrigé via un second module `account_cache.rs`** : le cache est désormais encapsulé dans un wrapper `{"data": {...}, "sig": ""}`. La clé HMAC 32 bytes est stockée dans le keychain (`user = "account-hmac-key"`), parallèlement aux tokens. Le chemin de gating (`license_commands::check_account_edition`) appelle `account_cache::load_verified` qui refuse tout payload non-signé ou avec signature invalide, et **fail-closed** (retourne None → Premium reste gated) si la clé HMAC est inaccessible. Le chemin d'affichage UI (`get_account_info` → `load_unverified`) accepte encore les anciens payloads non-signés pour que les utilisateurs upgradés voient leur profil immédiatement. La distinction display/verified est explicite dans l'API. ## Conséquences ### Positives - **Protection cryptographique native** : sous Windows, les tokens sont maintenant protégés par Credential Manager (DPAPI sous le capot). Sous Linux, par le keyring daemon avec une master password de session. - **Anti-tampering du gating licence** : écrire `account.json` ne débloque plus Premium. - **Fail-closed par défaut** : tous les chemins qui échouent sur le keychain retournent des erreurs au lieu de dégrader silencieusement. - **Migration transparente** : zéro action utilisateur pour les upgrades depuis v0.7.x. - **Anti-downgrade** : un attaquant ne peut pas forcer la dégradation vers le fichier pour capturer les tokens au prochain refresh. ### Négatives - **Dépendance Linux** : `libdbus-1-dev` est requis au build time (ajouté à `check.yml` et `release.yml`). Au runtime, `libdbus-1-3` est déjà présent sur toutes les distros desktop, mais une session headless sans D-Bus déclenche le fallback fichier (signalé à l'utilisateur par la bannière Settings). - **Surface d'attaque supply-chain accrue** : `keyring` + `dbus-secret-service` + `zbus` + `dbus` représentent une chaîne transitive nouvelle. Mitigé par un step `cargo audit` non-bloquant dans `check.yml`. - **Logs plus verbeux** : chaque fallback imprime un warning sur stderr pour qu'un dev puisse diagnostiquer. Pas de télémétrie. - **Sous Linux, la première utilisation peut demander un déverrouillage** : GNOME Keyring peut prompt l'utilisateur pour déverrouiller sa session keyring si elle ne l'est pas déjà. Ce comportement est natif du trousseau, pas de Simpl'Résultat. ### Tests - Tests unitaires : 9 nouveaux tests (serde round-trip de `StoredTokens`, parse/encode de `StoreMode`, zéroïfication, HMAC sign/verify, tamper detection, wrong key, envelope serde). - Tests manuels : matrice de 5 scénarios sur pop-os (Linux) et Windows, documentée dans l'issue #82. - Pas de mock du keychain en CI : la matrice manuelle couvre les chemins où une lib externe est requise. ## Références - Issue parente : maximus/simpl-resultat#66 - PRs : #83 (core), #84 (CI/packaging), #85 (subscription HMAC), #86 (UI banner), #87 (wrap-up) - CWE-212 : Improper Removal of Sensitive Information Before Storage or Transfer - CWE-312 : Cleartext Storage of Sensitive Information - CWE-345 : Insufficient Verification of Data Authenticity - CWE-757 : Selection of Less-Secure Algorithm During Negotiation - [keyring crate v3.6 documentation](https://docs.rs/keyring/3.6.3/keyring/) - [Secret Service API specification](https://specifications.freedesktop.org/secret-service-spec/latest/)