- New ADR-0006 documenting the OS keychain migration: context,
options considered (keyring vs stronghold vs AES-from-PIN), the
backend choice rationale (sync-secret-service vs async-secret-
service), anti-downgrade design, migration semantics, and the
subscription-tampering fix via account_cache.
- architecture.md updated: new token_store / account_cache module
entries, auth_commands descriptions now point at the keychain-
backed API, OAuth2 + deep-link flow diagram mentions the HMAC
step, command count bumped to 35.
- CHANGELOG.md + CHANGELOG.fr.md under Unreleased:
- Changed: tokens moved to keychain with transparent migration
and Settings banner on fallback.
- Changed: account cache is now HMAC-signed.
- Security: CWE-312 and CWE-345 explicitly closed.
Manual test matrix (pop-os + Windows) is tracked in issue #82 and
will be run by the release gatekeeper before the next tag.
Refs #66, #78, #79, #80, #81
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9.2 KiB
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 <app_data>/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 surtokens.jsonchiffré par permissions (0600Unix) si keychain indisponible.load(app) -> Option<StoredTokens>— lit depuis le keychain, migre untokens.jsonrésiduel à la volée.delete(app)— efface les deux stores (idempotent).store_mode(app) -> Option<StoreMode>— 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 cratedbus-secret-service→ cratedbus→ lib Clibdbus-1. Requiertlibdbus-1-devau build time etlibdbus-1-3au runtime (ce dernier est universellement présent sur les distributions desktop Linux). - Windows :
windows-native→ bind direct surwindows-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 <app_data>/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": "<HMAC-SHA256>"}. 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.jsonne 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-devest requis au build time (ajouté àcheck.ymletrelease.yml). Au runtime,libdbus-1-3est 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+dbusreprésentent une chaîne transitive nouvelle. Mitigé par un stepcargo auditnon-bloquant danscheck.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 deStoreMode, 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
- Secret Service API specification