Merge pull request 'docs: ADR 0006 + changelog + architecture for OAuth keychain (#82)' (#87) from issue-82-wrap-up into main
This commit is contained in:
commit
9ccfc7a9d9
4 changed files with 161 additions and 6 deletions
|
|
@ -2,6 +2,13 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
### Modifié
|
||||
- Les tokens OAuth sont maintenant stockés dans le trousseau du système d'exploitation (Credential Manager sous Windows, Secret Service sous Linux) au lieu d'un fichier JSON en clair. Les utilisateurs existants sont migrés de façon transparente au prochain rafraîchissement de session ; l'ancien fichier est écrasé avec des zéros puis supprimé. Une bannière « tokens en stockage local » apparaît dans les Paramètres si le trousseau est indisponible (#66, #78, #79, #81)
|
||||
- Le cache d'informations de compte est désormais signé par HMAC avec une clé stockée dans le trousseau : modifier manuellement le champ `subscription_status` dans `account.json` ne permet plus de contourner le gating Premium (#80)
|
||||
|
||||
### Sécurité
|
||||
- Correction de CWE-312 (stockage en clair des tokens OAuth) et CWE-345 (absence de vérification d'intégrité du cache d'abonnement). Les anciens fichiers `tokens.json` et les caches `account.json` non signés sont rejetés par le chemin de gating jusqu'à ce que le prochain rafraîchissement rétablisse un anchor de confiance dans le trousseau (#66)
|
||||
|
||||
## [0.7.3] - 2026-04-13
|
||||
|
||||
### Corrigé
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- OAuth tokens are now stored in the OS keychain (Credential Manager on Windows, Secret Service on Linux) instead of a plaintext JSON file. Existing users are migrated transparently on the next sign-in refresh; the old file is zeroed and removed. A "tokens stored in plaintext fallback" banner appears in Settings if the keychain is unavailable (#66, #78, #79, #81)
|
||||
- Cached account info is now HMAC-signed with a keychain-stored key: writing `subscription_status` to `account.json` manually can no longer bypass the Premium gate (#80)
|
||||
|
||||
### Security
|
||||
- Closed CWE-312 (cleartext storage of OAuth tokens) and CWE-345 (missing integrity check on the subscription cache). Legacy `tokens.json` and legacy unsigned `account.json` caches are rejected by the gating path until the next token refresh re-establishes a keychain-anchored trust (#66)
|
||||
|
||||
## [0.7.3] - 2026-04-13
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
127
docs/adr/0006-oauth-tokens-keychain.md
Normal file
127
docs/adr/0006-oauth-tokens-keychain.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# 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/)
|
||||
|
|
@ -153,7 +153,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useLicense` | État de la licence et entitlements |
|
||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||
|
||||
## Commandes Tauri (34)
|
||||
## Commandes Tauri (35)
|
||||
|
||||
### `fs_commands.rs` — Système de fichiers (6)
|
||||
|
||||
|
|
@ -199,12 +199,26 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
|
||||
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
|
||||
- `refresh_auth_token` — Rafraîchit l'access token via le refresh token
|
||||
- `get_account_info` — Lecture du cache `account.json` (sans appel réseau)
|
||||
- `check_subscription_status` — Vérifie l'abonnement (max 1×/jour, fallback cache gracieux)
|
||||
- `logout` — Efface tokens.json + account.json
|
||||
- `get_account_info` — Lecture du cache d'affichage (via `account_cache::load_unverified`, accepte les payloads legacy)
|
||||
- `check_subscription_status` — Vérifie l'abonnement (max 1×/jour, fallback cache gracieux). Déclenche aussi la migration `tokens.json` → keychain via `token_store::load`
|
||||
- `logout` — Efface tokens (`token_store`) + cache signé (`account_cache`) + clé HMAC du keychain
|
||||
|
||||
Note : `handle_auth_callback` n'est PAS exposée comme commande — elle est appelée depuis le handler deep-link `on_open_url` dans `lib.rs`. Voir section "OAuth2 et deep-link" plus bas.
|
||||
|
||||
### `token_store.rs` — Stockage des tokens OAuth (1)
|
||||
|
||||
- `get_token_store_mode` — Retourne `"keychain"`, `"file"` ou `null`. Utilisé par la bannière de sécurité `TokenStoreFallbackBanner` dans Settings pour alerter l'utilisateur quand les tokens sont dans le fallback fichier.
|
||||
|
||||
Module non-command : `save`, `load`, `delete`, `store_mode` — toute la logique de persistance passe par ce module, `auth_commands.rs` ne touche jamais directement `tokens.json`. Voir l'ADR 0006 pour la conception complète.
|
||||
|
||||
### `account_cache.rs` — Cache d'abonnement signé (aucune commande)
|
||||
|
||||
Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`. Expose :
|
||||
- `save(app, &AccountInfo)` — écrit l'enveloppe signée `{data, sig}` dans `account.json`, avec clé HMAC-SHA256 stockée dans le keychain.
|
||||
- `load_unverified(app)` — lecture pour affichage UI (accepte legacy et signé).
|
||||
- `load_verified(app)` — lecture pour gating licence (refuse legacy, tampering, absence de clé). Utilisé par `license_commands::check_account_edition`.
|
||||
- `delete(app)` — efface le fichier et la clé HMAC du keychain.
|
||||
|
||||
### `entitlements.rs` — Entitlements (1)
|
||||
|
||||
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition
|
||||
|
|
@ -234,7 +248,7 @@ Flow complet (v0.7.3+) :
|
|||
3. L'utilisateur s'authentifie (ou Logto auto-consent si session existante) → redirection 303 vers `simpl-resultat://auth/callback?code=...`
|
||||
4. L'OS route le custom scheme vers une nouvelle instance de l'app → `tauri-plugin-single-instance` (feature `deep-link`) détecte l'instance existante, **ne démarre PAS un nouveau processus**, et forwarde l'URL à l'instance vivante
|
||||
5. Le callback `app.deep_link().on_open_url(...)` enregistré via `DeepLinkExt` reçoit les URLs. Pour chaque URL :
|
||||
- Si un param `code` est présent → appelle `handle_auth_callback` (token exchange vers `/oidc/token`, fetch `/oidc/me`, écriture `tokens.json` + `account.json` avec perms 0600, émission de l'event `auth-callback-success`)
|
||||
- Si un param `code` est présent → appelle `handle_auth_callback` (token exchange vers `/oidc/token`, fetch `/oidc/me`, écriture des tokens via `token_store::save` (keychain OS, fallback fichier 0600) + cache signé via `account_cache::save` (HMAC-SHA256), émission de l'event `auth-callback-success`)
|
||||
- Si un param `error` est présent → émission de l'event `auth-callback-error` avec `error: error_description`
|
||||
6. Le hook `useAuth` (frontend) écoute `auth-callback-success` / `auth-callback-error` et met à jour l'état
|
||||
|
||||
|
|
@ -243,7 +257,7 @@ Pourquoi cet enchaînement est critique :
|
|||
- **Sans `on_open_url`** : l'ancien listener `app.listen("deep-link://new-url", ...)` ne recevait pas les URLs forwardées par single-instance. L'API canonique v2 via `DeepLinkExt` est nécessaire
|
||||
- **Sans gestion des erreurs** : un callback `?error=...` laissait l'UI bloquée en état "loading" infini
|
||||
|
||||
Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_commands.rs` (PKCE + token exchange), `src/hooks/useAuth.ts` (frontend).
|
||||
Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_commands.rs` (PKCE + token exchange), `src-tauri/src/commands/token_store.rs` (persistance keychain + fallback), `src-tauri/src/commands/account_cache.rs` (cache signé HMAC), `src/hooks/useAuth.ts` (frontend), `src/components/settings/TokenStoreFallbackBanner.tsx` (UI de l'état dégradé).
|
||||
|
||||
## Pages et routing
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue