- 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>
127 lines
9.2 KiB
Markdown
127 lines
9.2 KiB
Markdown
# 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 sur `tokens.json` chiffré par permissions (`0600` Unix) si keychain indisponible.
|
|
- `load(app) -> Option<StoredTokens>` — lit depuis le keychain, migre un `tokens.json` ré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 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 `<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.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/)
|