22 KiB
Spec — Migration des tokens OAuth vers le keychain OS (#66)
Contexte
Simpl'Résultat utilise depuis la v0.7.0 un flux OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (access_token, refresh_token, id_token) sont actuellement persistés dans le filesystem utilisateur :
- Chemin :
<app_data_dir>/auth/tokens.json - Protection Unix : permissions
0600(owner-only) viaOpenOptionsExt::mode(0o600) - Protection Windows : aucune (le fichier est écrit avec
fs::writesans ACL particulière) - Commentaire de code source (auth_commands.rs:7-11) : la solution actuelle est explicitement documentée comme transitoire, en attendant une migration vers le keychain OS.
Objectif de la présente spec : migrer le stockage des tokens OAuth de fichier plat vers le keychain OS natif (Credential Manager sur Windows, Secret Service sur Linux), avec migration transparente pour les utilisateurs existants et fallback gracieux si le keychain est indisponible.
Référence : CWE-312 (Cleartext Storage of Sensitive Information), issue Forgejo #66, liée à #51 (OAuth2 PKCE initial).
Problème
Ce qui est stocké en clair aujourd'hui
Le fichier auth_commands.rs écrit et lit 2 fichiers sensibles via la fonction write_restricted() (l.77-97) :
| Fichier | Contenu | Sensibilité | Usage |
|---|---|---|---|
tokens.json |
access_token, refresh_token, id_token, expires_at |
HAUTE | Tous les appels API authentifiés, rotation via refresh |
account.json |
email, nom, picture, subscription_status |
Moyenne | Affichage UI du compte, gating des features payantes |
last_check |
timestamp unix (dernier check abonnement) | Aucune | Throttling du polling Logto (24h) |
Trou principal : Windows
La fonction write_restricted() utilise OpenOptionsExt::mode(0o600) uniquement sous #[cfg(unix)]. Sur Windows, le fallback est un fs::write brut — aucune ACL appliquée. Or Windows est une plateforme cible supportée. Un autre processus utilisateur peut lire le fichier.
Trou secondaire : tous les OS
Même avec 0600 sur Linux, le contenu reste :
- Accessible à n'importe quel process tournant sous le même UID (malware utilisateur, extensions de navigateur local exfiltrant
$HOME, outils de debug mal configurés, backups non chiffrés synchronisés sur le cloud) - Inclus dans les backups de dossier home par défaut
- Lisible si l'attaquant obtient un shell non-root sur la machine
Le refresh token en particulier donne une session longue durée (rotation mais pas d'expiration courte) et représente le pire-cas d'exposition.
Pourquoi c'est le bon moment
- Avant la monétisation : la session Logto gatera l'accès aux features payantes (#53 machine activation, #50 Stripe). Un refresh token volé = contournement du gating de licence.
- La dette est connue : le code lui-même documente l'intention de migrer (auth_commands.rs:7-8).
- Changement bien scopé : le flux OAuth est stabilisé depuis v0.7.3, l'API de stockage est centralisée dans un seul fichier.
Solution proposée
Crate keyring (v3)
🟡 SECURITE — Pas de pinning de version ni de revue supply-chain.
keyringv3 tiresecret-servicequi pullszbuset un large graphe D-Bus — surface d'attaque non négligeable pour une app privacy-first. Resolution : Pinnerkeyring = "3.x"explicitement dans Cargo.toml, ajoutercargo audità check.yml après l'ajout de la dep, et documenter la chaîne de deps transitives dans l'ADR. Ref : OWASP A06:2021
Wrapper Rust maintenu au-dessus des keychains natifs :
| OS | Backend | Installé par défaut |
|---|---|---|
| Windows | Credential Manager (Win32 API) | Oui |
| Linux | Secret Service API via D-Bus (GNOME Keyring / KWallet) | Non — nécessite libsecret-1-0 |
| macOS | Keychain Services (hors cible) | Oui |
Alternatives considérées :
tauri-plugin-stronghold: chiffrement au repos avec une master password. Rejeté car demande à l'utilisateur de saisir une passphrase supplémentaire, ce qui casse l'UX de connexion silencieuse (refresh automatique au démarrage).tauri-plugin-store+ chiffrement custom : il faudrait gérer une clé maître quelque part — on déplace le problème.- AES-256-GCM avec clé dérivée du PIN : seulement viable pour les profils avec PIN, pas pour les tokens OAuth qui doivent être lus sans interaction.
keyring est le bon compromis : le système d'exploitation gère déjà la clé maître (session utilisateur).
Scope
🟡 SECURITE — L'exclusion de
account.jsonignore le tampering desubscription_status. Un malware local peut écrire"active"dedans pour bypass le gating de licence sans toucher au keychain. Resolution : Re-validersubscription_statusdepuis l'id_token/userinfo à chaque décision de gating, OU signer la valeur en cache, OU inclure account.json dans la migration keychain. Ref : CWE-345
🟢 ARCHITECTURE — Scope limité aux tokens est le bon compromis : blast radius minimal,
write_restricted()réutilisé tel quel, valeur sécurité ≈ coût du refactor. Garder tel quel et justifier l'asymétrie dans l'ADR.
Migration uniquement de tokens.json. Raisons :
account.jsoncontient de l'info d'affichage non-sensible (email déjà visible dans le menu, picture URL HTTP publique). Pas un secret opérationnel.last_checkest un timestamp.- Limiter le blast radius du changement, garder
write_restricted()pour le reste. - Permet un rollback ciblé si le keychain pose problème en production.
Architecture
Nouveau module src-tauri/src/auth/token_store.rs exposant 3 fonctions :
pub struct StoredTokens { /* identique à aujourd'hui */ }
pub fn save(app: &AppHandle, tokens: &StoredTokens) -> Result<(), String>;
pub fn load(app: &AppHandle) -> Result<Option<StoredTokens>, String>;
pub fn delete(app: &AppHandle) -> Result<(), String>;
🔴 SECURITE + ARCHITECTURE + TECHNIQUE — Service keychain ne correspond pas à l'identifiant bundle réel (
com.simpl.resultatdans tauri.conf.json vscom.lacompagniemaximus.simpl-resultatdans la spec). Resolution : Utilisercom.simpl.resultat(l'identifiant canonique de l'app) dans la constante, les commandessecret-toolde test et l'ADR. Aligner sinon tauri.conf.json en premier. Ref : CWE-1270
🟡 ARCHITECTURE + TECHNIQUE — Nouveau top-level module
auth/casse la conventioncommands/. Resolution : Placer le module àsrc-tauri/src/commands/token_store.rset l'enregistrer danscommands/mod.rscomme les autres. Pas de nouveau répertoire à créer.
Conventions :
- Une seule entrée keychain :
service = "com.lacompagniemaximus.simpl-resultat",user = "oauth-tokens" value = serde_json::to_string(&StoredTokens)(JSON compact, pas pretty)- Le module
auth_commands.rsne touche plus jamaistokens.jsondirectement — tous les accès passent partoken_store.
Implémentation — save()
🔴 SECURITE + ARCHITECTURE — Fallback silencieux annule l'objectif de sécurité. Sur Windows le fallback n'applique aucune ACL, et un user qui installe le
.debsans libsecret continuera à stocker ses tokens en clair sans aucun signal visible. Resolution : Sur Windows, appliquer une DACL restrictive viaSetNamedSecurityInfoWlors du fallback (ou fail-closed et forcer reauth). Exposer l'étatstore_mode: keychain|fileà la frontend pour afficher une bannière de sécurité si le fallback est actif. Ref : CWE-276
1. Essayer keyring::Entry::new(SERVICE, USER).set_password(&json)
2. Si OK : supprimer tokens.json résiduel (migration nettoyage)
3. Si KO : fallback write_restricted() sur tokens.json + log warning
Implémentation — load()
🔴 SECURITE — Migration laisse des tokens en clair récupérables.
fs::remove_filene zéroifie pas les blocs disque, et le refresh token (long-lived) reste récupérable via unallocated sectors ou backups existants — précisément le threat model cité dans la spec. Resolution : Avantremove_file, overwrite le contenu avec des zéros etfsync(), puis delete. Documenter dans le changelog la recommandation de rotation de session post-migration pour les utilisateurs inquiets des backups passés. Ref : CWE-212
1. Essayer keyring::Entry::new(...).get_password()
2. Si OK et valeur présente : désérialiser et retourner Some(tokens)
3. Si KO ou vide : tenter de lire tokens.json
3a. Si tokens.json présent : migrer (keyring write + file delete) et retourner Some(tokens)
3b. Si tokens.json absent : retourner Ok(None)
C'est dans cette fonction que la migration transparente se fait : à la première lecture après upgrade, les tokens passent du fichier au keychain.
Implémentation — delete()
1. Supprimer keychain entry (ignorer les erreurs "no entry")
2. Supprimer tokens.json (ignorer si absent)
Double-delete pour éviter les états "fantôme" où un reliquat traîne dans l'un des deux stores.
Refactor d'auth_commands.rs
Tous les call sites qui manipulent actuellement TOKENS_FILE passent par token_store :
| Ligne actuelle | Opération | Nouveau code |
|---|---|---|
| 202 (handle_auth_callback) | write après exchange | token_store::save(&app, &tokens) |
| 219-227 (refresh_auth_token) | read | token_store::load(&app)? |
| 277 (refresh_auth_token) | write après rotation | token_store::save(&app, &new_tokens) |
| 251 (refresh_auth_token) | delete sur échec refresh | token_store::delete(&app) |
| 305 (logout) | delete | token_store::delete(&app) |
| 320 (check_subscription_status) | exists check | token_store::load(&app)?.is_some() |
La constante TOKENS_FILE est supprimée du fichier (reste ACCOUNT_FILE et LAST_CHECK_FILE gérés par write_restricted comme avant).
Fallback gracieux
🟡 SECURITE — Aucune distinction entre "keychain n'a jamais marché" et "keychain marchait hier et échoue aujourd'hui". Un process hostile local pourrait forcer la dégradation. Resolution : Persister un flag
store_modedansapp_data_dir/auth/. Si le keychain a déjà fonctionné, un échec ultérieur doit refuser le plaintext et forcer reauth au lieu de dégrader silencieusement. Ref : CWE-757
Le fallback fichier s'active quand :
- L'appel
keyring::Entry::new()retourne une erreur (keyring crate indisponible) set_password()/get_password()retournePlatformFailureou équivalent- Spécifique Linux : D-Bus non démarré, libsecret absent, session sans keyring déverrouillé
Comportement attendu :
save()etload()logguent un warning une seule fois par session (pas de spam)- L'app reste fonctionnelle, juste avec le filet de sécurité
0600(Unix) ou rien (Windows sans keychain — très improbable puisque Credential Manager est toujours présent) - Aucune fenêtre d'erreur ne s'affiche à l'utilisateur
Migration des utilisateurs existants
🟡 TECHNIQUE — Timing incertain.
check_subscription_statusa un throttle 24h et un early-return surlast_checkrécent — un user qui relance souvent l'app peut voirtokens.jsontraîner indéfiniment. Resolution : Remplacer le checkdir.join(TOKENS_FILE).exists()à auth_commands.rs:320 partoken_store::load(&app)?.is_some()— ça force la migration dès la première lecture, sans attendre le throttle.
À la prochaine lecture (refresh_auth_token au démarrage via check_subscription_status), token_store::load() détecte tokens.json résiduel, le copie dans le keychain, et supprime le fichier. Pas de reconnexion forcée, pas de notification.
Si le keychain est indisponible, le fichier reste en place avec ses permissions 0600 — comportement équivalent à aujourd'hui.
Impact CI/CD et packaging
Linux — dépendance libsecret
🔴 SECURITE — Cible AppImage oubliée.
tauri.conf.jsoninclutappimagedansbundle.targets— ces builds n'héritent pas des deps apt et ne bundlent pas libsecret par défaut. Chaque user AppImage retombe silencieusement dans le fallback plaintext. Resolution : Soit bundler libsecret vialinuxdeploydans le build AppImage, soit retirer AppImage du scope v0.8, soit documenterlibsecret-1-0comme pré-requis système dans les release notes AppImage.
🔴 TECHNIQUE —
.forgejo/workflows/release.ymln'est pas mentionné. Le build Linux de release échouera au linking si libsecret-1-dev n'est pas installé là aussi. Resolution : Ajouterlibsecret-1-devaux steps d'install système Linux derelease.ymlen plus decheck.yml. Lister explicitement les deux workflows dans la spec.
Le crate keyring sous Linux utilise libsecret via D-Bus. Il faut :
- Dev :
libsecret-1-devinstallé sur les machines de build (pop-os du dev + workers Forgejo Actions). À vérifier si déjà présent. - Build .deb : ajouter
libsecret-1-0auxdependsdanstauri.conf.json→bundle.linux.deb.depends. - Build .rpm : ajouter
libsecretauxdependsdansbundle.linux.rpm.depends. - CI
check.yml: installerlibsecret-1-devavantcargo check/cargo test.
Sans ces ajouts, l'app compile mais au runtime le keychain échoue → fallback fichier → warning log. L'app reste fonctionnelle, mais la valeur de sécurité du changement est annulée pour les utilisateurs qui installent via .deb sans la dépendance.
Windows
Credential Manager est un service Windows built-in, toujours disponible. Aucune dépendance à déclarer.
Contrainte taille Credential Manager
🟢 SECURITE — Plan B "access_token en RAM only" crée une race au cold-start offline : chaque démarrage doit hit Logto avant toute call authentifiée, cassant la promesse offline-first pour 24h. Resolution : Si la limite 2.5 KB est atteinte, stocker
access_tokenetrefresh_tokendans deux entrées keychain distinctes (user=access, user=refresh) plutôt que sortir l'access token du store.
La limite théorique d'un credential Windows est ~2.5 KB (valeur stockée dans le CRED_BLOB). Un StoredTokens sérialisé pèse typiquement 1-1.8 KB (3 JWT + timestamp). À mesurer avec un vrai payload Logto avant le merge. Si on approche la limite, fallback possible : stocker uniquement le refresh token dans le keychain, garder l'access token en mémoire (il expire en 1h de toute façon).
Tests
Tests unitaires (impossibles pour la vraie partie keychain)
🟡 ARCHITECTURE + TECHNIQUE — L'injection de trait
Backendest YAGNI et techniquement infaisable :keyringv3 expose une structEntryconcrète sans trait public à swapper. Ajouter un wrapper trait juste pour les tests = abstraction sans contrepartie. Resolution : Drop le trait injection. Tester uniquement le round-trip serde surStoredTokenset le chemin fallback fichier (qui ne touche pas keyring). Marquer#[ignore]tous les tests qui nécessitent un vrai keychain.
Le crate keyring ne fournit pas de mock officiel. On teste :
- La sérialisation / désérialisation
StoredTokens(déjà couvert implicitement) - La logique de migration via un fake backend (trait
Backendinjecté pour les tests)
Tests manuels obligatoires avant merge
Sur pop-os (dev) :
- Fresh install : supprimer
<app_data>/auth/, lancer l'app, se connecter, vérifier qu'aucun fichiertokens.jsonn'est créé, vérifier viasecret-tool lookup service com.lacompagniemaximus.simpl-resultat user oauth-tokens - Migration : créer un
tokens.jsonartificiel (en repartant d'une ancienne version), lancer la nouvelle version, vérifier que le fichier est supprimé et le secret présent dans le keychain après le premier refresh - Logout : vérifier que le keychain entry ET le fichier résiduel sont effacés
- Fallback : masquer D-Bus (
DBUS_SESSION_BUS_ADDRESS=/dev/null), vérifier que l'app fonctionne et que le fallback fichier s'active
Sur Windows (VM ou machine dédiée) :
- Fresh install + login → vérifier présence dans Credential Manager (
rundll32.exe keymgr.dll,KRShowKeyMgr) - Migration depuis un
tokens.jsonartificiel - Logout
Tests CI
🔴 TECHNIQUE —
check.ymla deux jobs distincts (rustetfrontend, conteneur ubuntu:22.04). La spec ne le précise pas et parle d'"installer avant cargo check" comme si c'était une seule étape. Resolution : Éditer uniquement le step "Install system dependencies" du jobrust— appendlibsecret-1-devà la listeapt-get installexistante. Ne pas toucher le job frontend.
check.yml doit :
- Installer
libsecret-1-devavantcargo check/cargo test cargo testpasse (les tests qui nécessitent un vrai keychain sont marqués#[ignore], exécutés manuellement)
Documentation à mettre à jour
🟡 ARCHITECTURE + TECHNIQUE — Format de numérotation ADR incorrect. Les ADRs existants (0001-0005) utilisent un préfixe 4 chiffres sans
adr-. De plus,Securityn'est pas une catégorie du CHANGELOG du projet (voir.claude/rules/changelog.md). Resolution : Nommer le fichierdocs/adr/0006-oauth-tokens-keychain.md. Classer l'entrée changelog sousChanged(ouFixedsi framed comme correction de vulnérabilité), pasSecurity.
docs/architecture.md— section "Stockage" et "Commandes Tauri" : mentionnertoken_store, mettre à jour le diagramme de stockage authdocs/adr/— nouvel ADRadr-006-oauth-tokens-keychain.mddécrivant la décision (contexte, options considérées, fallback)CHANGELOG.md/CHANGELOG.fr.md— sectionSecurity:- EN :
Migrated OAuth tokens storage from plaintext JSON file to OS keychain (Credential Manager on Windows, Secret Service on Linux). Existing users are migrated transparently on first token refresh. - FR :
Migration du stockage des tokens OAuth d'un fichier JSON en clair vers le keychain du système (Credential Manager sous Windows, Secret Service sous Linux). Les utilisateurs existants sont migrés de façon transparente au premier rafraîchissement du token.
- EN :
Critères d'acceptance (issue #66)
- Tokens stockés dans le keychain OS → via
keyringcrate - Fallback gracieux si keychain indisponible → fallback
write_restricted()avec warning logué - Migration automatique des fichiers existants → dans
token_store::load()au premier appel - Linux packaging :
libsecret-1-0ajouté aux dépendances.deb/.rpm - CI
check.yml:libsecret-1-devinstallé avant les tests - ADR rédigé et mergé dans
docs/adr/ - Tests manuels passés sur pop-os + Windows (3 scénarios chacun)
Estimation
🟢 TECHNIQUE — Estimation optimiste. N'inclut pas les debug cycles CI, ni les surprises de linking pkg-config dans le conteneur ubuntu:22.04 du job
rust, ni le first-run libsecret de validation. Resolution : Monter à 4-5h et prévoir un premier push CI dédié à valider que libsecret compile avant d'écrire les tests.
- Module
token_store+ Cargo.toml : 45 min - Refactor
auth_commands.rs: 20 min - Mise à jour packaging (tauri.conf.json + check.yml) : 15 min
- Tests manuels pop-os : 30 min
- Tests manuels Windows (si VM dispo) : 30 min
- ADR + changelog + PR : 20 min
Total : ~2h30 à 3h
Risques identifiés
| Risque | Probabilité | Sévérité | Mitigation |
|---|---|---|---|
libsecret absent sur install .deb minimale → fallback silencieux annule le bénéfice |
Moyenne | Moyenne | Ajouter aux deps .deb, documenter dans changelog |
| Credential Manager dépasse la limite 2.5 KB | Basse | Haute | Mesurer avant merge, plan B : refresh token only au keychain |
| Migration échoue silencieusement (fichier gardé) | Basse | Basse | Double-write acceptable, logué en warning |
| Régression du flux OAuth existant (utilisateurs v0.7.x) | Basse | Haute | Tests manuels exhaustifs des 3 scénarios sur 2 OS |
| Keyring Linux demande un déverrouillage GNOME Keyring au démarrage | Moyenne | Basse | C'est le comportement attendu — documenter dans le changelog |
Questions ouvertes
- Scope account.json — on laisse hors scope comme proposé, ou on migre aussi ? Recommandation : hors scope.
- libsecret dans
.deb— ajout immédiat ou follow-up ? Recommandation : immédiat, sinon la migration n'a aucune valeur pour la majorité des utilisateurs Linux. - ADR format — réutiliser le gabarit existant de
docs/adr/(à vérifier s'il y en a un).
Revision — Synthese
Date: 2026-04-13 | Experts: Securite, Architecture, Technique
Verdict
🔴 CRITIQUES A CORRIGER — La spec est solide sur les principes, mais 6 trous critiques sont à colmater avant implémentation : identifiant bundle incohérent, fallbacks silencieux qui annulent le gain de sécurité, plaintext récupérable post-migration, AppImage et release.yml oubliés, structure CI mal comprise.
Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|---|---|---|---|---|
| Securite | 3 | 3 | 1 | Fallback silencieux, plaintext résiduel, AppImage, subscription tampering, supply chain |
| Architecture | 1 | 3 | 1 | Bundle id, module path, YAGNI trait, ADR numbering, scope OK |
| Technique | 3 | 2 | 1 | release.yml oublié, check.yml mal lu, migration timing, estimation optimiste |
Actions requises
- 🔴 Bundle identifier — aligner service keychain sur
com.simpl.resultat(tauri.conf.json) - 🔴 Fallback save() non-silencieux — DACL Windows OU fail-closed, exposer
store_modeau frontend - 🔴 Zéroification avant delete — overwrite + fsync avant
fs::remove_filedes tokens migrés - 🔴 AppImage libsecret — bundler via linuxdeploy, retirer du scope, ou documenter le pré-requis
- 🔴 release.yml libsecret-1-dev — ajouter aux steps Linux sinon le build release casse
- 🔴 check.yml job rust uniquement — append à "Install system dependencies", pas de nouveau step
- 🟡 Module path —
src-tauri/src/commands/token_store.rs, pas de nouveau top-levelauth/ - 🟡 Fallback integrity — flag
store_modepersisté, refus du downgrade si keychain a déjà marché - 🟡 Migration timing — remplacer
TOKENS_FILE.exists()ligne 320 partoken_store::load()?.is_some() - 🟡 Pin keyring + cargo audit —
keyring = "3.x"explicite,cargo auditdans CI - 🟡 subscription_status integrity — re-valider ou signer la valeur en cache
- 🟡 Drop trait Backend — tester uniquement round-trip serde + fallback fichier
- 🟡 ADR format —
0006-oauth-tokens-keychain.md, changelog sousChanged(pasSecurity)