From c9c7eaef814085ffe3d18c67ba73181dd0dcd4fa Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 21:03:49 +0200 Subject: [PATCH 01/34] =?UTF-8?q?fix(securite):=20audit=202026-08-03=20?= =?UTF-8?q?=E2=80=94=2016=20findings=20corriges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit manuel complet (lecture integrale, sans agent externe) : securite, coherence, patterns, structure. Rapport detaille dans CLAUDE-SECURITY-20260730-180405/AUDIT-MANUEL-20260803.md (non versionne). ELEVE - ML Kit / Play Services / telemetrie Google CCT embarques via mobile_scanner : declares honnetement (AntiFeatures NonFreeDep F-Droid, PRIVACY FR+EN §6/§10, THIRD_PARTY_NOTICES). Origine de ACCESS_NETWORK_STATE, absente du manifeste source. Le retrait de la dependance reste une decision produit. - SEC F6 : les deux coffres redivergeaient en taille des le franchissement d'un barreau (64 Kio), definitivement pour un leurre factice. Deni plausible au repos casse sur un simple `ls -l`. Realignement au deverrouillage (vrai leurre) et a l'ecriture (leurre factice), avec garde vitale sur le drapeau pt_decoy_configured. MOYEN - _unlockInternal etait la seule des trois fonctions d'ouverture sans try/catch (asymetrie introduite par F3 v2.4.4) : un fichier malforme laissait un indicateur de progression permanent, sans message ni issue. Garde par emplacement en plus : un leurre corrompu ne doit pas condamner le principal. - Parametres Argon2id ecrits dans les 3 formats mais jamais relus ; le .ptbak les lisait pour la derivation et bâtissait l'AAD sur les constantes. Un bump de owaspMobile2024 aurait rendu coffres, instantanes et sauvegardes indechiffrables en silence. Lecture via KdfParams.fromFileOrNull (bornes anti-DoS partagees), report des parametres a chaque reecriture. - deleteDecoyVault gardait le refus opaque que SEC F12 avait retire de deleteVault : meme traitement gracieux desormais (DecoyDeleteOutcome). - Chemin Heritage : 3 tampons de clair jamais effaces + disable() supprimait pt_heir.enc sans ecrasement alors que deleteVault le dechiquette. Shred unifie sur VaultService.shredFileSync (3 implementations coexistaient). - USE_FINGERPRINT reste reinjectee par biometric_storage : SEC F12 n'avait rien retire. Commentaire rectifie (minSdk 24, pas 26). FAIBLE - Branche v3 morte dans le deverrouillage biometrique + commentaire mensonger. - _shredShareCache : appel « au verrouillage » annonce mais inexistant ; purge avant/apres les deux exports. - shouldShowHeirOption() construit dans build() avec effet de bord d'ecriture. - .ptbak : count et exportedAt en clair hors AEAD, retires. - unlock() rend desormais UnlockResult.busy au lieu de « mot de passe incorrect » sur refus de concurrence. - network_security_config : bloc domain-config inerte retire. - MainActivity exported=false (les alias portent le LAUNCHER). - Camouflage : limite « Reglages > Applications » documentee. Trouve en relisant mes propres correctifs : le chemin biometrique ne peuplait pas le cache meta, ce qui privait silencieusement SEC F18 de sa migration d'etiquette pour quiconque n'ouvre qu'a l'empreinte. Tests : 126 -> 139. Le test de non-regression des parametres KDF a ete valide en reintroduisant temporairement le defaut (il echoue, et lui seul). flutter analyze : 0 issue. A TESTER SUR APPAREIL avant release : lancement par alias apres exported=false, sortie de la calculatrice, deverrouillage d'un coffre existant. Co-Authored-By: Claude Opus 5 (1M context) --- PRIVACY.fr.md | 31 +- PRIVACY.md | 29 +- THIRD_PARTY_NOTICES.md | 29 ++ android/app/src/main/AndroidManifest.xml | 55 ++- .../passtech/pass_tech/CalculatorActivity.kt | 23 +- .../main/res/xml/network_security_config.xml | 23 +- lib/l10n/app_en.arb | 4 - lib/l10n/app_fr.arb | 4 - lib/screens/settings_screen.dart | 82 ++-- lib/screens/unlock_screen.dart | 79 ++- lib/services/heritage_service.dart | 130 ++++- lib/services/import_export_service.dart | 119 +++-- lib/services/kdf_service.dart | 62 +++ lib/services/vault_crypto.dart | 16 +- lib/services/vault_migration.dart | 4 + lib/services/vault_service.dart | 454 ++++++++++++++---- lib/services/vault_setup.dart | 18 +- lib/services/vault_storage.dart | 40 +- lib/services/vault_unlock.dart | 168 +++++-- test/kdf_params_from_file_test.dart | 332 +++++++++++++ 20 files changed, 1435 insertions(+), 267 deletions(-) create mode 100644 test/kdf_params_from_file_test.dart diff --git a/PRIVACY.fr.md b/PRIVACY.fr.md index ac8997b..e28b006 100644 --- a/PRIVACY.fr.md +++ b/PRIVACY.fr.md @@ -59,11 +59,35 @@ La présente Politique de confidentialité explique comment l'application **Pass ## 6. Réseau -- L'app utilise le réseau pour **deux fonctions strictement à impact local** : - 1. **Vérification de mises à jour** : interroge `api.github.com/repos/gitubpatrice/pass_tech/releases/latest` (HTTPS, sans auth, sans cookie). +- **Pass Tech elle-même** n'utilise le réseau que pour **deux fonctions strictement à impact local** : + 1. **Vérification de mises à jour** : interroge `api.github.com/repos/gitubpatrice/pass_tech/releases/latest` (HTTPS, sans auth, sans cookie). Suspendue tant que le mode panique est actif. 2. **Vérification HIBP** (Have I Been Pwned, opt-in) : envoie uniquement les **5 premiers caractères du SHA-1** d'un mot de passe (modèle k-anonymity). Le mot de passe ne quitte jamais l'appareil. - Network Security Config refuse le HTTP en clair et les autorités utilisateur en release. -- Aucune télémétrie, rapport de crash ou analytics. +- **Nous n'opérons aucun serveur.** Aucune donnée n'est envoyée au développeur, aucun analytics maison, aucun rapport de crash. + +### Bibliothèque tierce : Google ML Kit (scan de QR code) + +Par souci d'exactitude, une troisième pile réseau est présente dans l'application, +et elle ne vient pas de notre code : + +- Le scan de QR code (utilisé pour importer un secret d'authentification à deux + facteurs) repose sur la bibliothèque `mobile_scanner`, qui s'appuie sur + **Google ML Kit**. Celle-ci embarque à son tour `play-services-basement`, + `play-services-base` et le composant de transport + `com.google.android.datatransport` (dit « CCT »), susceptible de remonter à + Google des statistiques d'utilisation de la bibliothèque. +- C'est également l'origine de la permission **`ACCESS_NETWORK_STATE`** visible + dans la fiche de l'application : elle est ajoutée automatiquement par cette + dépendance, et non déclarée par Pass Tech. +- **Ce composant n'a jamais accès au contenu de votre coffre.** Il ne voit que + l'image de la caméra au moment où vous scannez un QR code, et uniquement à ce + moment-là. Vos identifiants, votre mot de passe maître et vos clés ne + transitent par aucune bibliothèque Google. +- Si ce point vous importe, n'utilisez pas le scan de QR code : le secret + d'authentification à deux facteurs peut toujours être **saisi à la main**, et + toutes les autres fonctions de l'application s'en passent totalement. + +Cette dépendance est signalée par l'anti-fonctionnalité `NonFreeDep` sur F-Droid. ## 7. Partage et transmission de données @@ -108,6 +132,7 @@ Voir [SECURITY.md](./SECURITY.md). | `USE_BIOMETRIC` / `USE_FINGERPRINT` | Déverrouillage biométrique optionnel via Android BiometricPrompt. | | `INTERNET` | Vérification de mises à jour (GitHub Releases) et HIBP (k-anonymity, opt-in). | | `CAMERA` | Scanner un QR code 2FA pour ajouter un secret TOTP. Flux caméra traité localement, jamais enregistré. | +| `ACCESS_NETWORK_STATE` | **Non déclarée par Pass Tech.** Ajoutée automatiquement par la bibliothèque de scan de QR code (Google ML Kit / `datatransport`). Voir §6. | ## 11. Enfants diff --git a/PRIVACY.md b/PRIVACY.md index 68dbdd4..0b9ac69 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -59,11 +59,33 @@ This Privacy Policy explains how the **Pass Tech** application — a 100% local ## 6. Network -- The app uses the network for **two strictly local-impact functions** : - 1. **Update check** : queries `api.github.com/repos/gitubpatrice/pass_tech/releases/latest` (HTTPS, no auth, no cookie). +- **Pass Tech itself** uses the network for **two strictly local-impact functions** : + 1. **Update check** : queries `api.github.com/repos/gitubpatrice/pass_tech/releases/latest` (HTTPS, no auth, no cookie). Suspended while panic mode is active. 2. **HIBP breach check** (Have I Been Pwned, opt-in) : sends only the **first 5 characters of the SHA-1** of a password (k-anonymity model). The actual password never leaves the device. - Network Security Config rejects cleartext HTTP and user-installed CAs in release builds. -- No telemetry, crash reporting or analytics. +- **We operate no server.** No data is sent to the developer, no in-house analytics, no crash reporting. + +### Third-party library: Google ML Kit (QR code scanning) + +For the sake of accuracy, a third network stack is present in the app, and it does +not come from our code: + +- QR code scanning (used to import a two-factor authentication secret) relies on + the `mobile_scanner` library, which is built on **Google ML Kit**. That library + in turn bundles `play-services-basement`, `play-services-base` and the + `com.google.android.datatransport` transport component ("CCT"), which may report + library usage statistics back to Google. +- This is also where the **`ACCESS_NETWORK_STATE`** permission shown on the app + listing comes from: it is added automatically by that dependency, not declared + by Pass Tech. +- **This component never has access to your vault contents.** It only sees the + camera image at the moment you scan a QR code, and only then. Your credentials, + master password and keys never pass through any Google library. +- If this matters to you, do not use QR code scanning: a two-factor secret can + always be **typed in by hand**, and every other feature of the app works + entirely without it. + +This dependency is flagged with the `NonFreeDep` anti-feature on F-Droid. ## 7. Sharing and data transmission @@ -107,6 +129,7 @@ See [SECURITY.md](./SECURITY.md) for the vulnerability disclosure policy. | `USE_BIOMETRIC` / `USE_FINGERPRINT` | Optional biometric unlock via Android BiometricPrompt. | | `INTERNET` | Update check (GitHub Releases) and HIBP breach check (k-anonymity, opt-in). | | `CAMERA` | Scan a 2FA QR code to add a TOTP secret. The camera feed is processed locally and never recorded. | +| `ACCESS_NETWORK_STATE` | **Not declared by Pass Tech.** Added automatically by the QR scanning library (Google ML Kit / `datatransport`). See §6. | ## 11. Children diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 188a2a8..1b4ee8c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -51,6 +51,35 @@ Pass Tech uses standard, well-reviewed cryptographic primitives via the `cryptog No custom cryptography is implemented in this application. +## Transitive Android dependencies (not declared in `pubspec.yaml`) + +Audited 2026-08-03 against the **merged release manifest** +(`build/app/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml`). +Some Flutter plugins pull in native Android artifacts that never appear in +`pubspec.yaml`. The ones that matter here: + +| Pulled in by | Artifact | License | Note | +| ------------ | -------- | ------- | ---- | +| `mobile_scanner` | `com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.0` | Android Software Development Kit License (**proprietary**) | QR code scanning engine | +| `mobile_scanner` | `com.google.android.gms:play-services-base:18.1.0` | Android SDK License (**proprietary**) | | +| `mobile_scanner` | `com.google.android.gms:play-services-basement:18.1.0` | Android SDK License (**proprietary**) | | +| `mobile_scanner` | `com.google.mlkit:common:18.9.0`, `com.google.mlkit:vision-common:17.3.0` | Android SDK License (**proprietary**) | | +| `mobile_scanner` | `com.google.android.datatransport:transport-runtime:2.2.6`, `transport-backend-cct:2.3.3` | Apache-2.0 | Google telemetry transport ("CCT"). **Source of the `ACCESS_NETWORK_STATE` permission** and of the `JobInfoSchedulerService` / `AlarmManagerSchedulerBroadcastReceiver` components in the merged manifest. | +| `biometric_storage` | re-declares `android.permission.USE_FINGERPRINT` (no `maxSdkVersion`) | — | Required by `androidx.biometric` on API 24-27 | + +Consequences are documented for users in [`PRIVACY.md`](./PRIVACY.md) §6 and §10, +and declared to F-Droid via the `NonFreeDep` anti-feature. The QR scanner is the +**only** feature relying on this stack; a 2FA secret can always be typed in by +hand instead. + +To re-check after any dependency bump: + +```bash +grep -E "gms|mlkit|datatransport" \ + build/app/intermediates/manifest_merge_blame_file/release/\ +processReleaseMainManifest/manifest-merger-blame-release-report.txt +``` + ## External services queried by the user - **GitHub Releases API** — `https://api.github.com/repos/gitubpatrice/pass_tech/releases/latest`. Public, anonymous, HTTPS, no cookie. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 35e51fc..c7e6a54 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,16 +1,41 @@ - + + + + * dans le manifeste, et ces deux attributs ne sont PAS modifiables au + * runtime : Réglages → Applications continue d'afficher « Pass Tech » avec + * son icône, de même que le sélecteur de partage, le gestionnaire de + * permissions et l'écran d'utilisation de la batterie. + * Basculer les alias ne change que ce que dessine le lanceur. + * Conséquence directe pour le modèle de menace : face à quelqu'un qui + * OUVRE les réglages — un contrôle frontalier attentif, exactement le + * scénario qui motive ce mode — le camouflage ne tient pas. Il est conçu + * pour le regard porté sur un écran d'accueil, et c'est tout. + * Le rendre total supposerait un second APK (autre applicationId, autre + * label) : hors de portée d'un basculement d'alias. */ class CalculatorActivity : Activity() { diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index 64ed510..9c79991 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,15 +1,24 @@ + - - api.pwnedpasswords.com - api.github.com - - - - + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8b271d2..72c4a2e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -522,10 +522,6 @@ "settingsDeleteAllDialogTitle": "Delete all data?", "settingsDeleteAllDialogBody": "All your entries and the vault will be permanently deleted.", "settingsDeleteAllConfirm": "Delete everything", - "settingsDeleteAllUnavailable": "Operation unavailable.", - "@settingsDeleteAllUnavailable": { - "description": "Neutral refusal when a full wipe is requested from a decoy session. Must reveal NOTHING about the existence of a primary vault." - }, "exportPlainDialogTitle": "UNENCRYPTED export", "exportPlainWarningHeadline": "The exported file will contain ALL your passwords IN CLEAR TEXT (readable by anyone).", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0dbf561..128a916 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -505,10 +505,6 @@ "settingsDeleteAllDialogTitle": "Supprimer toutes les données ?", "settingsDeleteAllDialogBody": "Toutes vos entrées et le coffre-fort seront supprimés définitivement.", "settingsDeleteAllConfirm": "Tout supprimer", - "settingsDeleteAllUnavailable": "Opération indisponible.", - "@settingsDeleteAllUnavailable": { - "description": "Refus neutre quand la suppression totale est demandée depuis une session leurre. Ne doit RIEN révéler de l'existence d'un coffre principal." - }, "exportPlainDialogTitle": "Export NON CHIFFRÉ", "exportPlainWarningHeadline": "Le fichier exporté contiendra TOUS vos mots de passe EN CLAIR (lisibles par n'importe qui).", diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7dedf81..f13da3e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; -import 'package:files_tech_core/files_tech_core.dart'; import 'package:flutter/material.dart'; // ignore: unnecessary_import import 'package:flutter/semantics.dart'; @@ -527,27 +526,27 @@ class _SettingsScreenState extends State }, ); if (action != 'delete' || !mounted) return; + final nav = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); - // SEC-R3 v2.5.2 — depuis une session leurre, le service refuse et lève. + // La section « Coffre leurre » reste volontairement VISIBLE quel que soit + // l'emplacement actif : la masquer révélerait lequel est ouvert. Le code + // étant public sous Apache 2.0, un adversaire sait qu'une section absente + // signifierait « vous êtes dans le leurre ». // - // On garde volontairement la section « Coffre leurre » VISIBLE et on - // affiche un refus neutre, plutôt que de masquer l'entrée. Masquer - // révélerait l'emplacement actif : le code étant public sous Apache 2.0, - // un adversaire sait qu'une section absente signifie « vous êtes dans le - // leurre ». Même raisonnement que pour « Tout supprimer » (F12). - try { - await VaultService().deleteDecoyVault(); - } on StateError { - if (mounted) { - SnackUtils.showError( - context, - messenger, - t.settingsDeleteAllUnavailable, - ); - } + // AUDIT 2026-08-03 — le refus opaque qui suivait est supprimé. Depuis une + // session leurre, le service verrouille puis écrase, exactement comme + // « Tout supprimer » depuis cette même session (SEC F12). On revient alors + // au déverrouillage, puisque plus rien n'est ouvert — y rester afficherait + // les Réglages d'un coffre fermé. + final outcome = await VaultService().deleteDecoyVault(); + if (!mounted) return; + if (outcome == DecoyDeleteOutcome.sessionLocked) { + nav.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const UnlockScreen()), + (_) => false, + ); return; } - if (!mounted) return; setState(() {}); SnackUtils.showInfo(messenger, t.decoyDeletedSnack); } @@ -739,6 +738,13 @@ class _SettingsScreenState extends State final json = VaultService().exportJson(); final dir = await getTemporaryDirectory(); + // AUDIT 2026-08-03 — purge AVANT, en plus de la purge après. + // C'est la seule façon de rattraper un partage précédent interrompu : si le + // processus est tué pendant l'affichage du sélecteur, le `finally` ci-dessous + // ne s'exécute jamais et la copie faite par share_plus survit indéfiniment. + // Le commentaire de SEC F8 affirmait que ce ménage avait aussi lieu « au + // verrouillage » — c'était faux, la fonction n'avait qu'un seul appelant. + _shredShareCache(dir); final file = File('${dir.path}/pass_tech_export.json'); await file.writeAsString(json); try { @@ -763,21 +769,22 @@ class _SettingsScreenState extends State } /// Écrase un fichier par des octets aléatoires puis le supprime. - static void _shredFile(File file) { - try { - if (!file.existsSync()) return; - final len = file.lengthSync(); - if (len > 0) { - file.writeAsBytesSync(SecretBytes.randomBytes(len), flush: true); - } - file.deleteSync(); - } catch (_) {} - } + /// + /// AUDIT 2026-08-03 — délègue désormais à [VaultService.shredFileSync] au + /// lieu de refaire le travail. Cette copie locale n'avait pas le repli de + /// l'originale : quand l'écrasement échouait, elle abandonnait AUSSI la + /// suppression, laissant le fichier en clair intact. Deux traitements pour + /// un même besoin, dont un plus faible — le genre d'écart qui ne se voit + /// qu'à la relecture croisée. + static void _shredFile(File file) => VaultService.shredFileSync(file); /// Purge le répertoire de cache de `share_plus`, où le plugin recopie tout - /// fichier partagé. Appelé après chaque partage et au verrouillage, pour - /// couvrir aussi le cas où le processus est tué pendant l'affichage du - /// sélecteur (le `finally` ne s'exécute alors jamais). + /// fichier partagé et n'y fait le ménage qu'au DÉBUT du partage suivant. + /// + /// Appelé AVANT et APRÈS chaque partage. L'appel « avant » n'est pas + /// redondant : c'est lui qui rattrape le cas où le processus a été tué + /// pendant l'affichage du sélecteur, auquel cas le `finally` du partage + /// précédent n'a jamais tourné. static void _shredShareCache(Directory cacheDir) { try { final shareDir = Directory('${cacheDir.path}/share_plus'); @@ -811,6 +818,14 @@ class _SettingsScreenState extends State ); final date = DateTime.now().toIso8601String().substring(0, 10); final dir = await getTemporaryDirectory(); + // AUDIT 2026-08-03 — même purge que l'export en clair, avant et après. + // Ce chemin ne nettoyait PAS le cache de share_plus : la copie du + // `.ptbak` faite par le plugin y restait jusqu'au partage suivant. Le + // fichier est chiffré, donc l'enjeu est moindre qu'en clair — mais c'est + // une copie complète du coffre, laissée dans un répertoire sur lequel le + // plugin accorde une permission de lecture à toute application capable + // de répondre au sélecteur. + _shredShareCache(dir); final file = File('${dir.path}/pass_tech_$date.ptbak'); await file.writeAsString(content); if (!mounted) return; @@ -820,9 +835,8 @@ class _SettingsScreenState extends State XFile(file.path, mimeType: 'application/octet-stream'), ], subject: t.exportEncryptedShareSubject); } finally { - try { - if (file.existsSync()) file.deleteSync(); - } catch (_) {} + _shredFile(file); + _shredShareCache(dir); } } catch (e) { if (!mounted) return; diff --git a/lib/screens/unlock_screen.dart b/lib/screens/unlock_screen.dart index 88a3dcf..0e5b9f5 100644 --- a/lib/screens/unlock_screen.dart +++ b/lib/screens/unlock_screen.dart @@ -27,9 +27,23 @@ class _UnlockScreenState extends State { int? _lockoutRemaining; Timer? _lockoutTimer; + /// AUDIT 2026-08-03 — `Future` évalué UNE fois, à la création de l'écran. + /// + /// Il était auparavant construit directement dans `build()` + /// (`future: HeritageService().shouldShowHeirOption()`), donc relancé à + /// chaque reconstruction — et il y en a à chaque frappe d'erreur, chaque + /// bascule de chargement, chaque retour de biométrie. Chaque relance + /// enchaînait quatre lectures de stockage sécurisé **et un effet de bord + /// d'écriture** : `shouldShowHeirOption` appelle `startGraceIfNeeded`, qui + /// persiste le début du délai de grâce. Déclencher une écriture depuis une + /// méthode de rendu est une faute de conception en soi ; ici elle portait sur + /// l'horloge du dispositif d'héritage. + late final Future _heirOptionFuture; + @override void initState() { super.initState(); + _heirOptionFuture = HeritageService().shouldShowHeirOption(); _checkLockout(); _checkBiometric(); _checkIntegrity(); @@ -210,7 +224,20 @@ class _UnlockScreenState extends State { // unlockWithBiometric() triggers BiometricPrompt via biometric_storage — // the Keystore key is gated by setUserAuthenticationRequired(true), so a // successful read implies a successful biometric authentication. - final result = await VaultService().unlockWithBiometric(); + final UnlockResult result; + try { + result = await VaultService().unlockWithBiometric(); + } catch (e) { + // AUDIT 2026-08-03 — même filet que `_unlock()` : jamais d'indicateur de + // progression bloqué sur l'écran de déverrouillage. + if (!mounted) return; + final t = AppLocalizations.of(context); + setState(() { + _loading = false; + _error = t.genericError('$e'); + }); + return; + } if (!mounted) return; switch (result) { case UnlockResult.success: @@ -250,6 +277,18 @@ class _UnlockScreenState extends State { _error = t.unlockBiometricEnrollmentChanged; }); break; + case UnlockResult.busy: + // AUDIT 2026-08-03 — une autre ouverture est déjà en cours (typiquement + // l'utilisateur a validé son mot de passe puis posé son doigt). Rien + // n'a été tenté, donc aucun essai n'est consommé : on invite juste à + // recommencer, sans laisser entendre que la biométrie a échoué. + if (!mounted) return; + final t = AppLocalizations.of(context); + setState(() { + _loading = false; + _error = t.vaultBusyRetry; + }); + break; } } @@ -260,7 +299,28 @@ class _UnlockScreenState extends State { _loading = true; _error = null; }); - final result = await VaultService().unlock(pass); + final UnlockResult result; + try { + result = await VaultService().unlock(pass); + } catch (e) { + // AUDIT 2026-08-03 — filet de dernier recours. + // + // `VaultService.unlock()` est désormais fail-closed en interne, mais rien + // ne protégeait CET appel : la moindre exception qui remontait laissait + // `_loading` à `true`, donc un indicateur de progression PERMANENT, sans + // message, sans bouton, sans issue — et le même écran au relancement. + // La règle vaut au-delà de ce cas précis : sur l'écran de déverrouillage, + // aucun chemin ne doit pouvoir laisser l'utilisateur devant un coffre + // qu'il ne peut ni ouvrir ni comprendre. + _passCtrl.clear(); + if (!mounted) return; + final t = AppLocalizations.of(context); + setState(() { + _loading = false; + _error = t.genericError('$e'); + }); + return; + } _passCtrl.clear(); if (!mounted) return; switch (result) { @@ -304,6 +364,19 @@ class _UnlockScreenState extends State { _error = t.unlockWrongPassword; }); break; + case UnlockResult.busy: + // AUDIT 2026-08-03 — double-appui sur « Déverrouiller ». Avant, ce cas + // empruntait `wrongPassword` : la saisie était pourtant bonne et aucun + // essai n'avait été consommé, mais l'écran annonçait « mot de passe + // incorrect ». Sur l'écran le plus sensible de l'app, c'est une + // fausse alerte que l'utilisateur ne peut pas distinguer d'une vraie. + if (!mounted) return; + final t = AppLocalizations.of(context); + setState(() { + _loading = false; + _error = t.vaultBusyRetry; + }); + break; } } @@ -502,7 +575,7 @@ class _UnlockScreenState extends State { // FutureBuilder ne renvoie l'option qu'après le check // crypto, pas de leak temporel. FutureBuilder( - future: HeritageService().shouldShowHeirOption(), + future: _heirOptionFuture, builder: (_, snap) { if (snap.data != true) { return const SizedBox.shrink(); diff --git a/lib/services/heritage_service.dart b/lib/services/heritage_service.dart index 5d6bbdc..38974fc 100644 --- a/lib/services/heritage_service.dart +++ b/lib/services/heritage_service.dart @@ -56,10 +56,11 @@ class HeritageService { static const _defaultThresholdDays = 90; static const _gracePeriodDays = 7; - // Argon2id baseline — source unique : KdfParams.owaspMobile2024. - static final _argon2M = KdfParams.owaspMobile2024.memoryKiB; - static final _argon2T = KdfParams.owaspMobile2024.iterations; - static final _argon2P = KdfParams.owaspMobile2024.parallelism; + // AUDIT 2026-08-03 — alias `_argon2M/T/P` supprimés : ils servaient à la + // fois à l'écriture du fichier et à la construction de l'AAD, ce qui figeait + // la LECTURE sur une constante de compilation. Écriture = + // `KdfParams.owaspMobile2024`, citée là où l'on dérive ; lecture = + // `KdfParams.fromFileOrNull`, depuis l'instantané lui-même. /// True si un snapshot héritage a déjà été configuré. Future get isEnabled async { @@ -205,7 +206,16 @@ class HeritageService { // dérivation Argon2id ET à l'AAD (anti-downgrade). v1 reste lisible pour // les snapshots historiques (pas de break compat). final salt = SecretBytes.randomBytes(32); - final key = await KdfService.argon2id(password: heirPassword, salt: salt); + // Instantané neuf : on emploie la recommandation courante et on l'inscrit + // dans le fichier, qui la portera pour toute sa vie. C'est ce qui permettra + // à une version future de l'application, dotée d'autres réglages, de + // continuer à ouvrir cet instantané-ci. + const params = KdfParams.owaspMobile2024; + final key = await KdfService.argon2id( + password: heirPassword, + salt: salt, + params: params, + ); // v2.5.0 (F6) : ordre revu — fichier AVANT salt + rollback explicite si // l'écriture du salt échoue. Ancien ordre (salt → fichier) créait une // fenêtre de désync sur les updates : si l'écriture du fichier échouait @@ -219,7 +229,7 @@ class HeritageService { try { // 1) Fichier nouveau (atomic tmp+rename dans _writeSnapshotV2). Si // échec ici, ancien fichier + ancien salt intacts. - await _writeSnapshotV2(entries, key, salt); + await _writeSnapshotV2(entries, key, salt, params); try { // 2) Salt cohérent avec le nouveau fichier. await _storage.write(key: _saltKey, value: base64Encode(salt)); @@ -253,9 +263,26 @@ class HeritageService { } /// Supprime le snapshot et désactive l'héritage. + /// + /// AUDIT 2026-08-03 — le fichier est désormais DÉCHIQUETÉ, pas simplement + /// dissocié du répertoire. + /// + /// `pt_heir.enc` est une copie complète du coffre, chiffrée à partir du SEUL + /// mot de passe héritier : pas de liaison au Keystore, pas de `hwSecret`, + /// puisque l'héritier doit pouvoir la déchiffrer ailleurs. C'est donc le + /// fichier le plus attaquable hors ligne de toute l'application — et c'est + /// exactement pour cette raison que `deleteVault` le déchiquette + /// explicitement depuis SEC F1. Ce chemin-ci, celui que l'utilisateur + /// emprunte quand il désactive volontairement l'héritage, se contentait d'un + /// `deleteSync()` : les blocs restaient lisibles tels quels. + /// + /// Le seuil (`pt_heir_threshold_days`) et l'horodatage d'activité + /// (`pt_last_active_ts`) sont volontairement CONSERVÉS : le premier est un + /// réglage que l'utilisateur a choisi et qu'il retrouvera s'il réactive + /// l'héritage, le second est réécrit à chaque déverrouillage de toute façon. + /// Les effacer n'apporterait rien et ferait perdre un réglage. Future disable() async { - final f = await _heirFile(); - if (f.existsSync()) f.deleteSync(); + VaultService.shredFileSync(await _heirFile()); await _storage.delete(key: _saltKey); await _storage.delete(key: _enabledKey); await _storage.delete(key: _graceStartKey); @@ -281,12 +308,19 @@ class HeritageService { if (version == _heirVersionV2) { // v2 : Argon2id 32B → AES-GCM-256 + // AUDIT 2026-08-03 — paramètres relus dans l'instantané, avec les + // mêmes bornes que les deux autres formats. `null` = hors bornes, + // on refuse plutôt que de lancer un Argon2id sur des valeurs forgées. + final kdf = raw['kdf']; + final params = kdf is Map ? KdfParams.fromFileOrNull(kdf) : null; + if (params == null) return null; final key = await KdfService.argon2id( password: heirPassword, salt: salt, + params: params, ); try { - return await _readSnapshotV2(raw, key); + return await _readSnapshotV2(raw, key, params); } finally { SecretBytes.wipe(key); } @@ -325,9 +359,23 @@ class HeritageService { } /// AAD bound to a v2 heir snapshot (anti-downgrade). - Uint8List _aadV2(String saltB64) => Uint8List.fromList( + /// + /// AUDIT 2026-08-03 — [params] est un argument, et non plus les constantes + /// de compilation. Même défaut que dans le coffre et dans le `.ptbak` : + /// `kdf.m/t/p` étaient écrits dans l'instantané mais jamais relus, si bien + /// qu'un relèvement de [KdfParams.owaspMobile2024] aurait rendu illisibles + /// tous les instantanés existants. + /// + /// L'enjeu est ici particulier : cet instantané est ce que l'héritier + /// ouvrira, potentiellement des années après son écriture, avec une version + /// de l'application bien plus récente. C'est précisément le format sur + /// lequel la compatibilité en lecture ne doit jamais dépendre d'une + /// constante du moment. + Uint8List _aadV2(String saltB64, KdfParams params) => Uint8List.fromList( utf8.encode( - 'pt-heir:v=$_heirVersionV2|kdf=argon2id|m=$_argon2M|t=$_argon2T|p=$_argon2P|salt=$saltB64', + 'pt-heir:v=$_heirVersionV2|kdf=argon2id' + '|m=${params.memoryKiB}|t=${params.iterations}|p=${params.parallelism}' + '|salt=$saltB64', ), ); @@ -337,26 +385,42 @@ class HeritageService { List entries, Uint8List key, Uint8List salt, + KdfParams params, ) async { final saltB64 = base64Encode(salt); - final aad = _aadV2(saltB64); + final aad = _aadV2(saltB64, params); final plain = Uint8List.fromList( utf8.encode(jsonEncode(entries.map((e) => e.toJson()).toList())), ); - final res = await AeadService.encryptGcm( - key: key, - plaintext: plain, - aad: aad, - ); + final AeadResult res; + try { + res = await AeadService.encryptGcm(key: key, plaintext: plain, aad: aad); + } finally { + // AUDIT 2026-08-03 — `plain` contient le coffre ENTIER sérialisé en + // clair, mots de passe compris. Il n'était jamais effacé et restait donc + // en mémoire jusqu'au passage du ramasse-miettes. + // Toutes les fonctions jumelles le font pourtant : `_saveVaultV4` efface + // `ptBytes` ET `plainText`, `_decryptVaultV4` efface `pt`. Le chemin + // Héritage était le seul à ne pas suivre la règle de la maison. + try { + plain.fillRange(0, plain.length, 0); + } catch (_) { + /* vue non modifiable possible — cf. SecretBytes.wipe */ + } + } final out = { 'magic': 'PTHEIR', 'version': _heirVersionV2, 'kdf': { 'algo': 'argon2id', - 'm': _argon2M, - 't': _argon2T, - 'p': _argon2P, + // AUDIT 2026-08-03 — on écrit les paramètres RÉELLEMENT employés pour + // dériver `key`, et non les constantes lues séparément. Les deux + // coïncident aujourd'hui ; ce qui compte est qu'ils ne PUISSENT plus + // diverger, puisque `unlockAsHeir` relit désormais ces champs. + 'm': params.memoryKiB, + 't': params.iterations, + 'p': params.parallelism, 'salt': saltB64, }, 'cipher': { @@ -379,6 +443,7 @@ class HeritageService { Future?> _readSnapshotV2( Map raw, Uint8List key, + KdfParams params, ) async { try { if (raw['magic'] != 'PTHEIR') return null; @@ -389,7 +454,8 @@ class HeritageService { final nonce = base64Decode(cipher['nonce'] as String); final dataBlob = base64Decode(cipher['data'] as String); final split = AeadService.splitCipherAndTag(dataBlob); - final aad = _aadV2(saltB64); + // Mêmes paramètres que ceux ayant servi à dériver `key` (cf. appelant). + final aad = _aadV2(saltB64, params); final pt = await AeadService.decryptGcm( key: key, nonce: nonce, @@ -398,10 +464,24 @@ class HeritageService { aad: aad, ); if (pt == null) return null; - final list = jsonDecode(utf8.decode(pt)) as List; - return list - .map((e) => Entry.fromJson(e as Map)) - .toList(); + try { + final list = jsonDecode(utf8.decode(pt)) as List; + return list + .map((e) => Entry.fromJson(e as Map)) + .toList(); + } finally { + // AUDIT 2026-08-03 — symétrie avec `_decryptVaultV4`, qui efface son + // tampon déchiffré depuis la v2.4.0. Ici il ne l'était pas, alors que + // son contenu est identique : toutes les entrées en clair. + // En `finally` et non après le `jsonDecode` : un instantané corrompu + // fait lever le décodage, et c'est précisément le cas où le tampon + // serait resté intact en mémoire. + try { + pt.fillRange(0, pt.length, 0); + } catch (_) { + /* vue non modifiable possible */ + } + } } catch (_) { return null; } diff --git a/lib/services/import_export_service.dart b/lib/services/import_export_service.dart index 772dfdc..69a097e 100644 --- a/lib/services/import_export_service.dart +++ b/lib/services/import_export_service.dart @@ -368,11 +368,22 @@ class ImportExportService { static const _backupVersionV3 = 3; /// Construit l'AAD canonique v3 (anti-downgrade). - static List _aadV3(String saltB64) => utf8.encode( + /// + /// AUDIT 2026-08-03 — [params] est devenu un ARGUMENT. Cette fonction était + /// le cas le plus net du défaut : `importEncrypted` lisait bien `m`/`t`/`p` + /// dans le fichier pour dériver la clé, puis appelait `_aadV3` qui les + /// reconstruisait à partir des CONSTANTES. Deux sources de vérité + /// contradictoires dans une seule fonction. + /// + /// Tant que les deux coïncidaient, personne ne pouvait s'en apercevoir. Le + /// jour où [KdfParams.owaspMobile2024] est relevé, toutes les sauvegardes + /// déjà exportées cessent de s'ouvrir — sans message utile, puisque l'échec + /// d'étiquette AES-GCM est indistinguable d'une mauvaise phrase secrète. + static List _aadV3(String saltB64, KdfParams params) => utf8.encode( 'ptbak:v=$_backupVersionV3|kdf=argon2id' - '|m=${KdfParams.owaspMobile2024.memoryKiB}' - '|t=${KdfParams.owaspMobile2024.iterations}' - '|p=${KdfParams.owaspMobile2024.parallelism}' + '|m=${params.memoryKiB}' + '|t=${params.iterations}' + '|p=${params.parallelism}' '|salt=$saltB64', ); @@ -391,15 +402,33 @@ class ImportExportService { try { key = await KdfService.argon2id(password: passphrase, salt: salt); final saltB64 = base64Encode(salt); - final aad = Uint8List.fromList(_aadV3(saltB64)); + // Export : on emploie la recommandation courante et on l'inscrit dans le + // fichier ET dans l'AAD. Les deux resteront cohérents à la relecture, + // quelle que soit la valeur recommandée à ce moment-là. + final aad = Uint8List.fromList( + _aadV3(saltB64, KdfParams.owaspMobile2024), + ); final plain = Uint8List.fromList( utf8.encode(jsonEncode(entries.map((e) => e.toJson()).toList())), ); - final res = await AeadService.encryptGcm( - key: key, - plaintext: plain, - aad: aad, - ); + final AeadResult res; + try { + res = await AeadService.encryptGcm( + key: key, + plaintext: plain, + aad: aad, + ); + } finally { + // AUDIT 2026-08-03 — `plain` (toutes les entrées en clair) n'était + // jamais effacé, alors que la clé l'était juste en dessous. Même + // omission que dans `HeritageService._writeSnapshotV2` ; le chemin + // coffre (`_saveVaultV4`) le fait correctement depuis la v2.4.0. + try { + plain.fillRange(0, plain.length, 0); + } catch (_) { + /* vue non modifiable possible */ + } + } return jsonEncode({ 'magic': 'PTBAK', 'version': _backupVersionV3, @@ -415,8 +444,18 @@ class ImportExportService { // ciphertext || tag concaténés (compat AeadResult.cipherAndTag). 'data': base64Encode(res.cipherAndTag), }, - 'count': entries.length, - 'exportedAt': DateTime.now().toIso8601String(), + // AUDIT 2026-08-03 — `count` et `exportedAt` RETIRÉS. + // + // Ces deux champs étaient écrits EN CLAIR, hors du chiffrement et hors + // de l'AAD, dans le seul fichier de l'application destiné à quitter + // l'appareil (cloud, messagerie, clé USB). Ils annonçaient à quiconque + // met la main dessus combien d'identifiants la personne détient et + // quand elle a fait sa sauvegarde — sans rien apporter en échange : + // `importEncrypted` ne les a jamais relus, et la date figure déjà dans + // le nom du fichier (`pass_tech_AAAA-MM-JJ.ptbak`). + // + // Leur absence ne casse aucune sauvegarde existante, précisément parce + // qu'aucun code de lecture ne s'y réfère. }); } finally { if (key != null) SecretBytes.wipe(key); @@ -445,15 +484,15 @@ class ImportExportService { final cipher = json['cipher']; if (kdf is! Map || cipher is! Map) return null; if (kdf['algo'] != 'argon2id') return null; - final m = kdf['m'] as int? ?? 0; - final t = kdf['t'] as int? ?? 0; - final p = kdf['p'] as int? ?? 0; - // F1 v2.4.3 — bornes strictes anti-DoS sur params Argon2 forgés - // (un fichier malicieux pourrait spécifier m=1Go t=64 pour épuiser - // la RAM/CPU au déchiffrement). - if (m < 4096 || m > 1024 * 1024) return null; - if (t < 1 || t > 16) return null; - if (p < 1 || p > 4) return null; + // F1 v2.4.3 — bornes strictes anti-DoS sur params Argon2 forgés (un + // fichier malicieux pourrait spécifier m=1Go t=64 pour épuiser la + // RAM/CPU au déchiffrement). + // AUDIT 2026-08-03 — bornes déplacées dans `KdfParams.fromFileOrNull`, + // partagées avec le coffre et l'instantané héritier. Elles étaient + // recopiées ici seulement, alors que les deux autres formats relisaient + // leurs paramètres sans les vérifier du tout. + final params = KdfParams.fromFileOrNull(kdf); + if (params == null) return null; final saltB64 = kdf['salt'] as String? ?? ''; final salt = base64Decode(saltB64); if (salt.length < 16) return null; @@ -466,14 +505,13 @@ class ImportExportService { key = await KdfService.argon2id( password: passphrase, salt: salt, - params: KdfParams( - memoryKiB: m, - iterations: t, - parallelism: p, - outLen: 32, - ), + params: params, ); - final aad = Uint8List.fromList(_aadV3(saltB64)); + // AUDIT 2026-08-03 — l'AAD est construite avec LES MÊMES paramètres + // que la dérivation ci-dessus. C'est le correctif central : les deux + // lignes étaient auparavant en désaccord dès que le fichier ne + // portait pas exactement les constantes de compilation. + final aad = Uint8List.fromList(_aadV3(saltB64, params)); final plain = await AeadService.decryptGcm( key: key, nonce: nonce, @@ -482,16 +520,23 @@ class ImportExportService { aad: aad, ); if (plain == null) return null; - final list = jsonDecode(utf8.decode(plain)) as List; - final entries = []; - for (final item in list) { - try { - entries.add(Entry.fromJson(item as Map)); - } catch (_) {} + // AUDIT 2026-08-03 — effacement déplacé dans un `finally`. + // Il était placé APRÈS le `jsonDecode` : sur un fichier corrompu (ou + // forgé), le décodage lève, le `catch` extérieur rend `null`, et le + // tampon en clair survivait intact en mémoire. Le cas d'erreur est + // justement celui où l'on veut être sûr d'avoir nettoyé. + try { + final list = jsonDecode(utf8.decode(plain)) as List; + final entries = []; + for (final item in list) { + try { + entries.add(Entry.fromJson(item as Map)); + } catch (_) {} + } + return entries; + } finally { + SecretBytes.wipe(plain); } - // Wipe plaintext bytes après parsing JSON. - SecretBytes.wipe(plain); - return entries; } finally { if (key != null) SecretBytes.wipe(key); } diff --git a/lib/services/kdf_service.dart b/lib/services/kdf_service.dart index 84cb22f..63346f8 100644 --- a/lib/services/kdf_service.dart +++ b/lib/services/kdf_service.dart @@ -41,12 +41,74 @@ class KdfParams { }); /// OWASP 2024 baseline for password manager unlock on mobile. + /// + /// ⚠️ Cette constante est la valeur d'ÉCRITURE : elle s'applique aux coffres, + /// instantanés et sauvegardes **créés maintenant**. Elle n'est plus la valeur + /// de LECTURE — voir [fromFileOrNull]. C'est ce découplage qui permet de la + /// faire évoluer un jour sans rendre illisible ce qui existe déjà. static const owaspMobile2024 = KdfParams( memoryKiB: 19456, iterations: 2, parallelism: 1, outLen: 32, ); + + // Bornes de lecture. Elles ne disent pas ce qui est RECOMMANDÉ (c'est le rôle + // de `owaspMobile2024`) mais ce qu'on accepte d'un fichier sans se mettre en + // danger : un fichier hostile annonçant m = 1 Gio ou t = 64 épuiserait la + // mémoire et le processeur de l'appareil rien qu'à l'ouverture. + static const _minMemoryKiB = 4096; + static const _maxMemoryKiB = 1024 * 1024; + static const _maxIterations = 16; + static const _maxParallelism = 4; + + /// Relit les paramètres Argon2id **écrits dans un fichier**. + /// + /// AUDIT 2026-08-03 — jusqu'ici, `kdf.m` / `kdf.t` / `kdf.p` étaient + /// sérialisés dans les trois formats (coffre v4, instantané héritier v2, + /// sauvegarde `.ptbak` v3) mais **jamais relus** : la dérivation employait + /// systématiquement les constantes de compilation. Les champs du fichier + /// étaient purement décoratifs — exactement le piège que la v2.3.2 avait + /// supprimé pour le champ `aad` du coffre, au motif qu'« un dev futur + /// croirait ce champ autoritaire ». + /// + /// Le `.ptbak` faisait pire : `importEncrypted` lisait bien m/t/p pour + /// dériver la clé, mais construisait l'AAD à partir des constantes. Deux + /// sources de vérité contradictoires dans une seule fonction. + /// + /// Conséquence si l'on relevait un jour [owaspMobile2024] — ce que la veille + /// OWASP finira par imposer : **tous les coffres, instantanés et sauvegardes + /// existants deviendraient indéchiffrables d'un coup**, en silence, sous le + /// message « mot de passe incorrect ». Aucun garde-fou n'existait, et aucun + /// test ne couvrait le cas (ceux du `.ptbak` ne forgent que des valeurs HORS + /// bornes, jamais des valeurs valides mais différentes). + /// + /// Contrat : + /// - champs absents → [fallback] (fichier d'une version qui ne les écrivait + /// pas ; en pratique aucune, mais on ne casse rien) ; + /// - champs présents et sains → ces valeurs-là ; + /// - champs présents mais hors bornes ou du mauvais type → `null`, + /// l'appelant DOIT refuser le fichier (fail-closed). + static KdfParams? fromFileOrNull( + Map kdf, { + KdfParams fallback = owaspMobile2024, + int outLen = 32, + }) { + final m = kdf['m']; + final t = kdf['t']; + final p = kdf['p']; + if (m == null && t == null && p == null) return fallback; + if (m is! int || t is! int || p is! int) return null; + if (m < _minMemoryKiB || m > _maxMemoryKiB) return null; + if (t < 1 || t > _maxIterations) return null; + if (p < 1 || p > _maxParallelism) return null; + return KdfParams( + memoryKiB: m, + iterations: t, + parallelism: p, + outLen: outLen, + ); + } } /// Marker so callers know which algorithm was used for the derived key. diff --git a/lib/services/vault_crypto.dart b/lib/services/vault_crypto.dart index 710421c..f426628 100644 --- a/lib/services/vault_crypto.dart +++ b/lib/services/vault_crypto.dart @@ -16,9 +16,18 @@ part of 'vault_service.dart'; extension VaultCrypto on VaultService { /// Build the AAD string bound to a v4 vault. The exact bytes are part of /// the GCM tag — encrypt and decrypt MUST agree byte-for-byte. - Uint8List _aadV4(String alias) => Uint8List.fromList( + /// + /// AUDIT 2026-08-03 — les paramètres sont désormais un ARGUMENT et non plus + /// les constantes de compilation. Le chiffrement passe ceux qu'il écrit dans + /// le fichier, le déchiffrement ceux qu'il y a lus : les deux côtés restent + /// donc d'accord même si la valeur recommandée par le projet change un jour. + /// Auparavant, relever [KdfParams.owaspMobile2024] aurait changé l'AAD de + /// TOUS les coffres déjà écrits, et leur étiquette GCM ne se serait plus + /// jamais vérifiée. + Uint8List _aadV4(String alias, KdfParams params) => Uint8List.fromList( utf8.encode( - 'pt:v=4|alias=$alias|kdf=argon2id|m=${VaultService._argon2M}|t=${VaultService._argon2T}|p=${VaultService._argon2P}', + 'pt:v=4|alias=$alias|kdf=argon2id' + '|m=${params.memoryKiB}|t=${params.iterations}|p=${params.parallelism}', ), ); @@ -151,6 +160,7 @@ extension VaultCrypto on VaultService { Future?> _decryptVaultV4( Map raw, Uint8List finalKey, + KdfParams params, ) async { try { if (raw['magic'] != VaultService._vaultMagic) return null; @@ -170,7 +180,7 @@ extension VaultCrypto on VaultService { final dataBlob = base64Decode(cipher['data'] as String); final split = AeadService.splitCipherAndTag(dataBlob); - final aad = _aadV4(alias); + final aad = _aadV4(alias, params); final pt = await AeadService.decryptGcm( key: finalKey, nonce: nonce, diff --git a/lib/services/vault_migration.dart b/lib/services/vault_migration.dart index f62571a..d6682a4 100644 --- a/lib/services/vault_migration.dart +++ b/lib/services/vault_migration.dart @@ -68,6 +68,10 @@ extension VaultMigration on VaultService { salt: newSalt, wrappedDek: wrap.ciphertext, wrapNonce: wrap.nonce, + // La clé vient d'être redérivée : c'est l'un des trois moments où + // adopter la recommandation COURANTE est correct (avec la création du + // coffre et le changement de mot de passe maître). + params: KdfParams.owaspMobile2024, ); // V1 v2.4.0 — salt en storage écrit APRÈS le save vault réussi (aligné diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 69a4a20..4e30f64 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -117,6 +117,20 @@ enum UnlockResult { /// Réglages. À distinguer de [wrongPassword] (annulation utilisateur /// silencieuse) pour donner un message clair au lieu d'un échec opaque. biometricInvalidated, + + /// AUDIT 2026-08-03 — une autre opération détient déjà `_unlockGate`. + /// + /// Avant, ce refus de concurrence empruntait [wrongPassword] : un double-appui + /// rapide sur « Déverrouiller » affichait « mot de passe incorrect » alors que + /// la saisie était bonne et qu'AUCUN essai n'avait été consommé. Message + /// alarmant et trompeur sur l'écran le plus sensible de l'app. + /// + /// `changeMasterPassword` disposait déjà d'une sentinelle dédiée + /// ([VaultService.vaultBusy]) pour exactement ce cas — c'est le même + /// raisonnement, simplement jamais propagé à `unlock()`. + /// + /// ⚠️ Ne déclenche AUCUN comptage d'échec : rien n'a été tenté. + busy, } /// Identifie quel slot du vault est en cours d'utilisation. @@ -139,6 +153,22 @@ enum _Slot { primary, decoy } /// un [decoyOnly] le coffre principal est toujours là (écran de /// déverrouillage). Pousser l'écran de création après un [decoyOnly] serait /// dangereux : y créer un coffre écraserait le principal. +/// Ce que [VaultService.deleteDecoyVault] a fait de la session en cours. +/// +/// L'appelant DOIT s'en servir pour choisir l'écran suivant : après un +/// [sessionLocked] le coffre ouvert vient d'être écrasé, donc plus rien n'est +/// déverrouillé et il faut revenir à l'écran de déverrouillage. +enum DecoyDeleteOutcome { + /// L'appel venait du coffre principal : le leurre a été remplacé par un + /// leurre factice, la session en cours n'est pas touchée. + keptSession, + + /// L'appel venait de la session LEURRE elle-même : cette session a été + /// verrouillée AVANT l'écrasement, comme le fait `deleteVault`. Le coffre + /// principal, lui, est intact et toujours invisible. + sessionLocked, +} + enum VaultDeleteOutcome { /// Session principale : tout est détruit, coffres et KEK Keystore compris. fullWipe, @@ -256,11 +286,15 @@ class VaultService { static const _currentVersion = 4; static const _vaultMagic = 'PTVAULT'; - // Argon2id baseline — source unique : KdfParams.owaspMobile2024. - // (decision verrouillée — ROADMAP_HARDENING.md §3). - static final _argon2M = KdfParams.owaspMobile2024.memoryKiB; - static final _argon2T = KdfParams.owaspMobile2024.iterations; - static final _argon2P = KdfParams.owaspMobile2024.parallelism; + // AUDIT 2026-08-03 — les alias `_argon2M` / `_argon2T` / `_argon2P` ont été + // supprimés. Ils recopiaient `KdfParams.owaspMobile2024` et servaient à la + // fois à l'écriture du fichier ET à la construction de l'AAD, ce qui figeait + // la lecture sur une constante de compilation. + // + // Désormais : la valeur d'écriture est `KdfParams.owaspMobile2024`, citée + // explicitement là où l'on crée ou redérive une clé ; la valeur de lecture + // vient du fichier, via `KdfParams.fromFileOrNull`. Un alias intermédiaire + // ne ferait que brouiller cette distinction, qui est tout l'enjeu. // Brute-force protection: progressive lockout after 5 fails static const _failThreshold = 5; @@ -282,6 +316,25 @@ class VaultService { Uint8List? _cachedWrappedDek; Uint8List? _cachedWrapNonce; + /// AUDIT 2026-08-03 — paramètres Argon2id de l'emplacement ACTUELLEMENT + /// ouvert, tels que lus dans son fichier. + /// + /// Ils appartiennent au même lot que `_cachedSalt` : ce sont les entrées qui + /// ont produit la clé en cours d'utilisation. Toute réécriture du coffre doit + /// donc les REPORTER tels quels, exactement comme elle reporte le sel. + /// + /// Sans ce report, la relecture des paramètres introduirait elle-même une + /// perte de données : un coffre portant des paramètres différents des + /// constantes serait ouvert avec les siens, puis la première modification + /// d'entrée le réécrirait en annonçant les constantes — alors que son contenu + /// resterait chiffré sous l'ancienne clé. Le déverrouillage suivant + /// dériverait à partir des constantes annoncées et échouerait définitivement. + /// + /// Changer de paramètres est donc, par construction, réservé aux opérations + /// qui redérivent la clé : création du coffre, migration v3→v4, changement de + /// mot de passe maître. + KdfParams? _cachedKdfParams; + bool get isOpen => _isOpen; List get entries => List.unmodifiable(_entries); @@ -355,18 +408,78 @@ class VaultService { Future unlock(String masterPassword) async { // F5 — refus immédiat si un unlock concurrent tourne déjà. if (_unlockGate != null) { - return UnlockResult.wrongPassword; + return UnlockResult.busy; } final gate = _unlockGate = Completer(); try { return await _unlockInternal(masterPassword); } finally { - gate.complete(); + if (!gate.isCompleted) gate.complete(); _unlockGate = null; } } + /// AUDIT 2026-08-03 — enveloppe fail-closed de [_unlockInternalUnguarded]. + /// + /// `_unlockInternal` était la SEULE des trois fonctions d'ouverture à ne pas + /// être protégée : `_tryUnlockSlot` et `_passwordMatchesPrimaryInternal` + /// enveloppent toutes deux leur corps dans un `try/catch` qui rend un échec. + /// L'asymétrie vient du refactor F3 v2.4.4, qui a déplacé le chemin + /// d'ouverture PRINCIPAL de `_tryUnlockSlot` (protégée) vers ici. + /// + /// Ce qui n'était protégé nulle part : + /// • `jsonDecode(await file.readAsString())` et son cast en Map ; + /// • dans `_v4Unlock`, les `base64Decode` et les casts d'en-tête, placés + /// AVANT son propre `try`. + /// + /// L'exception traversait `unlock()` (`try`/`finally`, sans `catch`) puis + /// `_unlock()` côté écran, qui ne l'attrape pas davantage : `_loading` + /// restait à `true` et l'utilisateur se retrouvait devant un indicateur de + /// progression PERMANENT, sans message ni sortie, à chaque tentative. Même + /// famille que SEC F19 (l'impasse dont on ne sort qu'en tuant le processus). + /// + /// Atteignabilité : l'écriture du coffre est atomique (`tmp` + `rename`), il + /// faut donc une corruption du système de fichiers ou un accès root pour + /// produire un fichier malformé. Probabilité faible, conséquence sévère. Future _unlockInternal(String masterPassword) async { + try { + return await _unlockInternalUnguarded(masterPassword); + } catch (_) { + // Fail-closed, aligné sur `_tryUnlockSlot` : on n'ouvre rien et on ne + // laisse aucun matériel de clé derrière soi. + _wipeKey(); + _entries = []; + _isOpen = false; + _activeSlot = null; + return UnlockResult.wrongPassword; + } + } + + /// Consomme ~1 Argon2id « pour rien », afin que le temps total d'une + /// tentative d'ouverture ne dépende pas du nombre d'emplacements réellement + /// présents et lisibles. Sans cela, un chronomètre distingue un appareil qui + /// porte un coffre leurre d'un appareil qui n'en porte pas — et le déni + /// plausible tombe sans qu'on ait eu à déchiffrer quoi que ce soit. + /// + /// Le mot de passe est CONSTANT et sans rapport avec la saisie : le coût + /// d'Argon2id croît avec la longueur de l'entrée, donc hacher le vrai mot de + /// passe maître rouvrirait un canal de fuite (marginal) sur sa longueur. + /// + /// AUDIT 2026-08-03 — ce bloc était recopié à l'identique à trois endroits + /// (deux dans `_unlockInternal`, un dans `_tryUnlockSlot`) et un quatrième + /// site en avait désormais besoin. Factorisé ici plutôt que dupliqué une + /// fois de plus : c'est une garantie de sécurité, elle doit avoir une seule + /// définition. + Future _consumeDummyArgon2() async { + final dummySalt = SecretBytes.randomBytes(32); + final dummyOut = await KdfService.argon2id( + password: 'pt_dummy_noop_v2', + salt: dummySalt, + ); + SecretBytes.wipe(dummyOut); + } + + Future _unlockInternalUnguarded(String masterPassword) async { if (await getLockoutRemaining() != null) return UnlockResult.lockedOut; // Déni plausible : on tente UNE PASSE Argon2id sur CHAQUE slot, même si // un slot précédent a matché. Sinon le timing révèle l'existence du @@ -386,78 +499,99 @@ class VaultService { Uint8List? winnerSalt; Uint8List? winnerWrappedDek; Uint8List? winnerWrapNonce; + KdfParams? winnerParams; bool winnerIsV3 = false; - for (final slot in _Slot.values) { - final file = await _vaultFileFor(slot); - if (!await file.exists()) { - // Anti-timing : consomme ~1 Argon2id même si le slot n'existe pas. - // Password constant (pt_dummy_noop_v2) pour éviter une dépendance - // linéaire du coût Argon2id avec la longueur du masterPassword - // (timing oracle marginal sinon). - final dummySalt = SecretBytes.randomBytes(32); - final dummyOut = await KdfService.argon2id( - password: 'pt_dummy_noop_v2', - salt: dummySalt, - ); - SecretBytes.wipe(dummyOut); - continue; - } - final raw = jsonDecode(await file.readAsString()) as Map; - final version = raw['version'] as int? ?? 1; - - if (version >= _currentVersion) { - // ── Path v4 pur (refactor F3 v2.4.4) ──────────────────────────── - final r = await _v4Unlock( - slot: slot, - password: masterPassword, - raw: raw, - ); - if (r == null) continue; - if (matchedSlot == null) { - matchedSlot = slot; - winnerKey = r.finalKey; - winnerEntries = r.entries; - winnerSalt = r.salt; - winnerWrappedDek = r.wrappedDek; - winnerWrapNonce = r.wrapNonce; - } else { - // 2ᵉ slot déchiffre aussi (user a réutilisé le même password — - // erreur de config). Wipe immédiatement TOUS les buffers du - // perdant : sa clé v4, son cache méta, et ses entries (liste de - // String laissée à la merci du GC, mais on déréférence vite). - SecretBytes.wipe(r.finalKey); - SecretBytes.wipe(r.salt); - SecretBytes.wipe(r.wrappedDek); - SecretBytes.wipe(r.wrapNonce); - // r.entries sera GC après cette itération (référence locale). + // AUDIT 2026-08-03 — si une exception traverse malgré tout la boucle + // (Argon2id en manque de mémoire, `path_provider` indisponible…), les + // tampons du gagnant DÉJÀ capturés — dont sa clé de coffre — resteraient + // en RAM jusqu'au passage du ramasse-miettes, à une date non déterministe + // et hors de portée de `_wipeKey()`, qui n'agit que sur `_key`. + // On les efface avant de laisser l'exception remonter vers la garde + // fail-closed de `_unlockInternal`. Même raisonnement que SEC F21. + try { + for (final slot in _Slot.values) { + final file = await _vaultFileFor(slot); + if (!await file.exists()) { + // Anti-timing : consomme ~1 Argon2id même si le slot n'existe pas. + await _consumeDummyArgon2(); + continue; } - } else { - // ── Path v3 legacy (rare, vault non-migré depuis v2.0.0) ─────── - // Si on a déjà un winner v4, on skip la migration v3 du perdant — - // on ne migre que pour le winner (cas v3 winner ci-dessous). - // Anti-timing : consomme ~1 Argon2id dummy pour ne pas révéler - // qu'on a déjà un winner. - if (matchedSlot != null) { - final dummySalt = SecretBytes.randomBytes(32); - final dummyOut = await KdfService.argon2id( - password: 'pt_dummy_noop_v2', - salt: dummySalt, - ); - SecretBytes.wipe(dummyOut); + // AUDIT 2026-08-03 — lecture + parse protégés PAR EMPLACEMENT, et non + // seulement par la garde globale de `_unlockInternal`. + // + // Un `pt_vault_b.enc` corrompu ne doit JAMAIS empêcher d'ouvrir + // `pt_vault_a.enc` : avec une garde uniquement globale, la première + // exception rencontrée condamnait l'ouverture des DEUX emplacements, + // alors que le coffre principal était parfaitement lisible. + // On consomme le Argon2id factice pour que l'emplacement illisible reste + // indiscernable d'un emplacement absent du point de vue du chronomètre. + final Map raw; + final int version; + try { + raw = jsonDecode(await file.readAsString()) as Map; + version = raw['version'] as int? ?? 1; + } catch (_) { + await _consumeDummyArgon2(); continue; } - final r = await _tryUnlockSlot(slot, masterPassword); - if (r == UnlockResult.success) { - // Le path v3 mute déjà `_key`/`_entries`/`_isOpen`/`_activeSlot` - // et déclenche la migration v3→v4 inline. On capture juste un - // marqueur pour ne pas réécrire l'état au final. - matchedSlot = slot; - winnerKey = Uint8List.fromList(_key!); - winnerEntries = List.from(_entries); - winnerIsV3 = true; + + if (version >= _currentVersion) { + // ── Path v4 pur (refactor F3 v2.4.4) ──────────────────────────── + final r = await _v4Unlock( + slot: slot, + password: masterPassword, + raw: raw, + ); + if (r == null) continue; + if (matchedSlot == null) { + matchedSlot = slot; + winnerKey = r.finalKey; + winnerEntries = r.entries; + winnerSalt = r.salt; + winnerWrappedDek = r.wrappedDek; + winnerWrapNonce = r.wrapNonce; + winnerParams = r.params; + } else { + // 2ᵉ slot déchiffre aussi (user a réutilisé le même password — + // erreur de config). Wipe immédiatement TOUS les buffers du + // perdant : sa clé v4, son cache méta, et ses entries (liste de + // String laissée à la merci du GC, mais on déréférence vite). + SecretBytes.wipe(r.finalKey); + SecretBytes.wipe(r.salt); + SecretBytes.wipe(r.wrappedDek); + SecretBytes.wipe(r.wrapNonce); + // r.entries sera GC après cette itération (référence locale). + } + } else { + // ── Path v3 legacy (rare, vault non-migré depuis v2.0.0) ─────── + // Si on a déjà un winner v4, on skip la migration v3 du perdant — + // on ne migre que pour le winner (cas v3 winner ci-dessous). + // Anti-timing : consomme ~1 Argon2id dummy pour ne pas révéler + // qu'on a déjà un winner. + if (matchedSlot != null) { + await _consumeDummyArgon2(); + continue; + } + final r = await _tryUnlockSlot(slot, masterPassword); + if (r == UnlockResult.success) { + // Le path v3 mute déjà `_key`/`_entries`/`_isOpen`/`_activeSlot` + // et déclenche la migration v3→v4 inline. On capture juste un + // marqueur pour ne pas réécrire l'état au final. + matchedSlot = slot; + winnerKey = Uint8List.fromList(_key!); + winnerEntries = List.from(_entries); + winnerIsV3 = true; + } } } + } catch (_) { + if (winnerKey != null) SecretBytes.wipe(winnerKey); + if (winnerSalt != null) SecretBytes.wipe(winnerSalt); + if (winnerWrappedDek != null) SecretBytes.wipe(winnerWrappedDek); + if (winnerWrapNonce != null) SecretBytes.wipe(winnerWrapNonce); + winnerEntries = null; + rethrow; } if (matchedSlot != null && winnerKey != null) { @@ -476,11 +610,16 @@ class VaultService { _cachedSalt = winnerSalt; _cachedWrappedDek = winnerWrappedDek; _cachedWrapNonce = winnerWrapNonce; + _cachedKdfParams = winnerParams; await _onUnlockSuccess(); // SEC F18 v2.5.4 — réécrit ce coffre s'il porte encore l'étiquette // héritée. Pour le leurre, c'est ce qui retire du disque le mot // « decoy » écrit en clair. await _migrateFileLabelIfLegacy(matchedSlot); + // AUDIT 2026-08-03 — seul moment où l'on détient la clé de cet + // emplacement : si son fichier est resté sur un barreau inférieur à + // celui du voisin, c'est maintenant ou jamais qu'on le réaligne. + await _realignPaddingIfNeeded(matchedSlot); } else { // SEC F21 v2.5.4 — le chemin v3 a DÉJÀ posé `_key` / `_entries` // lui-même ; `winnerKey` n'est qu'une copie de contrôle, créée par @@ -561,6 +700,11 @@ class VaultService { _cachedSalt = null; _cachedWrappedDek = null; _cachedWrapNonce = null; + // Les paramètres ne sont pas un secret (ils sont en clair dans le fichier), + // mais ils décrivent la session ouverte : ils partent avec elle, sinon un + // `_saveVault` ultérieur les reporterait sur un coffre qu'ils ne décrivent + // plus. + _cachedKdfParams = null; // F17 v2.3.7 — reset la référence BiometricStorageFile pour qu'un // unlock biométrique post-panic re-acquière le storage proprement // (sinon référence dangling vers un fichier potentiellement supprimé). @@ -655,10 +799,17 @@ class VaultService { /// /// Retourne 0 (aucune contrainte) si le fichier est absent, illisible, ou au /// format v3 hérité qui n'a pas de champ `cipher`. - Future _otherSlotPlainLength(_Slot slot) async { - final other = slot == _Slot.primary ? _Slot.decoy : _Slot.primary; + Future _otherSlotPlainLength(_Slot slot) => + _plainLengthOf(_otherSlot(slot)); + + static _Slot _otherSlot(_Slot slot) => + slot == _Slot.primary ? _Slot.decoy : _Slot.primary; + + /// Longueur du CLAIR (rembourrage compris) de l'emplacement [slot], calculée + /// sans posséder sa clé — voir [_otherSlotPlainLength] pour le raisonnement. + Future _plainLengthOf(_Slot slot) async { try { - final f = await _vaultFileFor(other); + final f = await _vaultFileFor(slot); if (!f.existsSync()) return 0; final raw = jsonDecode(await f.readAsString()); if (raw is! Map) return 0; @@ -706,6 +857,81 @@ class VaultService { } } + /// AUDIT 2026-08-03 — remet les deux emplacements sur le MÊME barreau quand + /// celui de [slot] a pris du retard. + /// + /// SEC F6 rembourre à l'écriture, en s'alignant sur le voisin. Mais il ne + /// s'aligne QUE quand on écrit : dès que le coffre principal franchit un + /// barreau (64 Kio de JSON, soit quelques centaines d'entrées), il passe à + /// 256 Kio pendant que l'autre fichier reste à 64 Kio. Rien ne le rattrapait + /// ensuite. Pour un utilisateur sans vrai leurre, l'écart devenait + /// PERMANENT : le leurre factice n'est réécrit qu'à la création du coffre ou + /// à la réparation du schéma de fichiers. + /// + /// Résultat, `ls -l` donnait ~87 Ko contre ~350 Ko — le plus gros étant le + /// vrai coffre. C'est exactement ce que SEC F6 dit vouloir empêcher : « le + /// déni plausible que le nommage neutre `_a`/`_b` existe pour offrir tombait + /// sur un simple `ls -l` ». + /// + /// Ce point-ci traite le cas du VRAI leurre : on ne peut réécrire un + /// emplacement que si l'on détient sa clé, donc au déverrouillage. Le cas du + /// leurre FACTICE est traité à l'écriture, par + /// [_realignDummyDecoyIfSmaller], puisqu'il ne sera jamais déverrouillé. + /// + /// Best-effort et silencieux : un échec ne doit jamais empêcher l'ouverture. + Future _realignPaddingIfNeeded(_Slot slot) async { + try { + final own = await _plainLengthOf(slot); + final other = await _plainLengthOf(_otherSlot(slot)); + if (own <= 0 || other <= 0) return; + if (_ladderRungFor(own) >= _ladderRungFor(other)) return; + // `_saveVault` rembourre sur le barreau couvrant les DEUX longueurs : + // le simple fait de réécrire suffit à réaligner. + await _saveVault(); + } catch (_) { + /* réessai au prochain déverrouillage */ + } + } + + /// Pendant de [_realignPaddingIfNeeded] pour le leurre FACTICE, qui n'est + /// jamais déverrouillé et ne peut donc pas se réaligner tout seul. + /// + /// Appelée après chaque écriture du coffre principal. Ne fait rien tant que + /// les deux fichiers sont sur le même barreau — c'est-à-dire quasiment + /// toujours, les barreaux étant espacés d'un facteur 4. Quand elle agit, elle + /// coûte un Argon2id (~1 s) : c'est le prix d'un franchissement de barreau, + /// événement qui n'arrive qu'une poignée de fois dans la vie d'un coffre. + /// + /// ⚠️ GARDE VITALE : ne régénère QUE si l'on sait POSITIVEMENT qu'aucun vrai + /// leurre n'est configuré. Le drapeau doit valoir exactement `'false'` ; une + /// lecture qui échoue, qui rend `null` ou toute autre valeur fait renoncer. + /// Se tromper ici détruirait le second coffre de l'utilisateur, dont le + /// contenu n'existe nulle part ailleurs. + Future _realignDummyDecoyIfSmaller({ + required _Slot writtenSlot, + required int writtenPlainLen, + required int otherPlainLen, + }) async { + try { + // Seule l'écriture du PRINCIPAL peut entraîner la régénération du leurre + // factice. Depuis le leurre, on ne détient pas la clé du principal. + if (writtenSlot != _Slot.primary) return; + if (_ladderRungFor(otherPlainLen) >= _ladderRungFor(writtenPlainLen)) { + return; + } + // Fail-safe : le drapeau doit valoir EXACTEMENT 'false'. Une lecture + // absente, vide ou en échec fait renoncer — on ne régénère jamais « dans + // le doute », puisque se tromper détruirait un vrai coffre leurre. + final flag = await _storage.read(key: _decoyConfiguredKey); + if (flag != 'false') { + return; + } + await _createDummyDecoy(); + } catch (_) { + /* best-effort : nouvelle tentative à la prochaine écriture */ + } + } + /// Réécrit le coffre de [slot] si son étiquette de fichier est héritée. /// /// À appeler juste après un déverrouillage v4 réussi, quand `_key`, `_entries` @@ -724,12 +950,23 @@ class VaultService { final salt = _cachedSalt; final wrappedDek = _cachedWrappedDek; final wrapNonce = _cachedWrapNonce; - if (salt == null || wrappedDek == null || wrapNonce == null) return; + // AUDIT 2026-08-03 — les paramètres du coffre OUVERT sont reportés tels + // quels : cette réécriture ne redérive rien, elle ne fait que changer + // l'étiquette. Y écrire les constantes annoncerait une dérivation que la + // clé en cours ne respecte pas. + final params = _cachedKdfParams; + if (salt == null || + wrappedDek == null || + wrapNonce == null || + params == null) { + return; + } await _saveVaultV4( slot: slot, salt: salt, wrappedDek: wrappedDek, wrapNonce: wrapNonce, + params: params, ); } catch (_) { /* réessai au prochain déverrouillage */ @@ -790,6 +1027,18 @@ class VaultService { } } + /// Point d'entrée PUBLIC du déchiquetage de fichier. + /// + /// AUDIT 2026-08-03 — il existait trois traitements distincts pour le même + /// besoin : `_shredSync` ici, une copie approchante dans l'écran Réglages + /// (`_shredFile`, sans le repli en cas d'échec d'écrasement), et RIEN du tout + /// dans `HeritageService.disable()`, qui se contentait d'un `deleteSync()` + /// alors que `deleteVault` déchiquette explicitement ce même `pt_heir.enc`. + /// Trois niveaux de rigueur pour supprimer des fichiers de même sensibilité. + /// + /// Une seule définition désormais, et c'est la plus stricte des trois. + static void shredFileSync(File file) => _shredSync(file); + /// Écrase le contenu d'un fichier par des octets aléatoires avant l'unlink, /// pour qu'une récupération des blocs sous-jacents ne rende pas le clair. /// Best-effort : sur un système de fichiers à copie sur écriture (F2FS, @@ -814,10 +1063,18 @@ class VaultService { } } - /// Levée par [deleteDecoyVault] quand l'appel vient d'une session leurre. - /// L'appelant doit présenter ce refus sans révéler qu'un coffre principal - /// existe — le message ne doit RIEN dire de plus que « indisponible ». - static const decoySessionDeleteRefused = 'pt_delete_refused_decoy_session'; + /// AUDIT 2026-08-03 — `decoySessionDeleteRefused` a été SUPPRIMÉE. + /// + /// Cette sentinelle faisait lever [deleteDecoyVault] quand l'appel venait + /// d'une session leurre, et l'écran Réglages la traduisait en « opération + /// indisponible ». Or la v2.5.4 avait justement retiré ce refus opaque de + /// [deleteVault] (SEC F12), avec ce raisonnement — qui vaut mot pour mot + /// ici : « un message "opération indisponible" est en soi une anomalie ; + /// sous contrainte, il signale à l'adversaire que quelque chose est protégé, + /// et le propriétaire légitime le lit comme une panne ». + /// + /// Le raisonnement n'avait simplement jamais été propagé à cette + /// fonction-ci. Il l'est désormais : voir [DecoyDeleteOutcome]. /// SEC F10 v2.5.2 — sentinelle levée par `changeMasterPassword` quand le mot /// de passe actuel fourni ne correspond pas. L'appelant doit la traduire en @@ -937,24 +1194,35 @@ class VaultService { /// factice non-déverrouillable et on remet le flag à 'false'. Après ça, un /// attaquant forensic voit toujours 2 fichiers indistinguables et ne peut /// pas prouver qu'un decoy réel a existé. - Future deleteDecoyVault() async { - // SEC-R3 v2.5.2 — garde analogue à celle de `deleteVault` (F12), qui - // manquait ici. Cette fonction écrase `pt_vault_b.enc` par un leurre - // factice dont le mot de passe aléatoire n'est JAMAIS persisté : le - // fichier devient définitivement non déverrouillable. + Future deleteDecoyVault() async { + // SEC-R3 v2.5.2 — le danger identifié reste entièrement valable : cette + // fonction écrase `pt_vault_b.enc` par un leurre factice dont le mot de + // passe aléatoire n'est JAMAIS persisté, donc le fichier devient + // définitivement indéchiffrable. Or la section « Coffre leurre » des + // Réglages s'affiche selon `hasDecoyVault`, un drapeau GLOBAL et non + // l'emplacement actif : on peut donc demander cette suppression depuis la + // session leurre elle-même, et écraser le coffre qu'on vient d'ouvrir. // - // Or la section « Coffre leurre » des Réglages s'affiche selon - // `hasDecoyVault`, un drapeau GLOBAL, et non selon l'emplacement actif. - // Un utilisateur se trouvant dans sa propre session leurre pouvait donc - // écraser le coffre qu'il avait justement ouvert. Les buffers en RAM - // réparent la situation au prochain CRUD, mais verrouiller ou fermer - // l'app avant toute autre écriture perdait le vrai contenu — sans - // recours, y compris pour le propriétaire légitime. + // AUDIT 2026-08-03 — la parade change de nature, pas de rigueur. + // Le refus opaque est remplacé par le traitement gracieux que SEC F12 a + // introduit pour `deleteVault` : on fait ce qui est demandé, mais dans le + // bon ordre. Un « opération indisponible » sous contrainte trahit qu'il y a + // quelque chose à protéger ; ici, la personne qui manipule l'app obtient + // exactement ce qu'elle demande — l'emplacement leurre disparaît — et le + // coffre principal reste intact et invisible. if (_activeSlot == _Slot.decoy) { - throw StateError(decoySessionDeleteRefused); + // Ordre IMPÉRATIF, identique à `deleteVault` : verrouiller AVANT + // d'écraser. `_entries` et `_key` tiennent encore le contenu du leurre ; + // tout CRUD survenant entre l'écrasement et le verrouillage le + // réécrirait par-dessus le leurre factice qu'on vient de poser. + lock(); + await _createDummyDecoy(); + await _storage.write(key: _decoyConfiguredKey, value: 'false'); + return DecoyDeleteOutcome.sessionLocked; } await _createDummyDecoy(); await _storage.write(key: _decoyConfiguredKey, value: 'false'); + return DecoyDeleteOutcome.keptSession; } // ── Internal helpers (paths) ──────────────────────────────────────────────── diff --git a/lib/services/vault_setup.dart b/lib/services/vault_setup.dart index e9f550e..76f9211 100644 --- a/lib/services/vault_setup.dart +++ b/lib/services/vault_setup.dart @@ -47,6 +47,9 @@ extension VaultSetup on VaultService { salt: salt, wrappedDek: wrap.ciphertext, wrapNonce: wrap.nonce, + // Coffre neuf : on adopte la recommandation courante, et le fichier la + // portera pour toute sa vie. + params: KdfParams.owaspMobile2024, ); // V1 v2.4.0 — salt en storage écrit APRÈS save vault réussi : un kill // mid-flight ne laisse plus de salt orphelin pointant vers un vault @@ -143,6 +146,11 @@ extension VaultSetup on VaultService { salt: salt, wrappedDek: wrap.ciphertext, wrapNonce: wrap.nonce, + // Changement de mot de passe maître : la clé est intégralement + // redérivée, c'est donc le bon moment pour adopter la recommandation + // courante. Un coffre créé sous d'anciens paramètres se met ainsi à + // niveau tout seul le jour où son propriétaire change de mot de passe. + params: KdfParams.owaspMobile2024, ); // V1 v2.4.0 — salt en storage écrit APRÈS save vault réussi : si le // process est tué entre les deux, on garde l'ancien salt cohérent @@ -335,7 +343,7 @@ extension VaultSetup on VaultService { final aead = await AeadService.encryptGcm( key: finalKey, plaintext: ptBytes, - aad: _aadV4(fileLabel), + aad: _aadV4(fileLabel, KdfParams.owaspMobile2024), ); final out = { @@ -343,9 +351,11 @@ extension VaultSetup on VaultService { 'version': VaultService._currentVersion, 'kdf': { 'algo': 'argon2id', - 'm': VaultService._argon2M, - 't': VaultService._argon2T, - 'p': VaultService._argon2P, + // Leurre factice : `pwHash` ci-dessus a été dérivé avec les + // paramètres par défaut, on annonce donc exactement ceux-là. + 'm': KdfParams.owaspMobile2024.memoryKiB, + 't': KdfParams.owaspMobile2024.iterations, + 'p': KdfParams.owaspMobile2024.parallelism, 'salt': base64Encode(salt), }, 'kek': { diff --git a/lib/services/vault_storage.dart b/lib/services/vault_storage.dart index e7429cc..5ba850b 100644 --- a/lib/services/vault_storage.dart +++ b/lib/services/vault_storage.dart @@ -28,12 +28,14 @@ extension VaultStorage on VaultService { final cSalt = _cachedSalt; final cWrap = _cachedWrappedDek; final cNonce = _cachedWrapNonce; - if (cSalt != null && cWrap != null && cNonce != null) { + final cParams = _cachedKdfParams; + if (cSalt != null && cWrap != null && cNonce != null && cParams != null) { await _saveVaultV4( slot: slot, salt: cSalt, wrappedDek: cWrap, wrapNonce: cNonce, + params: cParams, ); return; } @@ -56,12 +58,20 @@ extension VaultStorage on VaultService { final wrappedB64 = kek['wrappedDek'] as String?; final nonceB64 = kek['wrapNonce'] as String?; if (saltB64 == null || wrappedB64 == null || nonceB64 == null) return; + // AUDIT 2026-08-03 — les paramètres Argon2id sont repris du fichier au même + // titre que le sel et l'enveloppe : ce sont les entrées qui ont produit la + // clé sous laquelle ce coffre est chiffré. Les remplacer par les constantes + // de compilation reviendrait à annoncer une dérivation que la clé en cours + // ne respecte pas — le déverrouillage suivant échouerait définitivement. + final params = KdfParams.fromFileOrNull(kdf); + if (params == null) return; await _saveVaultV4( slot: slot, salt: base64Decode(saltB64), wrappedDek: base64Decode(wrappedB64), wrapNonce: base64Decode(nonceB64), + params: params, ); } @@ -70,11 +80,17 @@ extension VaultStorage on VaultService { /// Caller must have populated `_key` with the 32-byte finalKey beforehand. /// `salt`, `wrappedDek`, `wrapNonce` are stable across CRUD writes — /// generated once at vault creation / migration and reused. + /// [params] : les paramètres Argon2id sous lesquels `_key` a été dérivée. + /// Ils sont ÉCRITS dans le fichier et entrent dans l'AAD. Les appelants qui + /// redérivent la clé (création, migration v3→v4, changement de mot de passe) + /// passent [KdfParams.owaspMobile2024] ; tous les autres reportent ceux du + /// coffre existant — voir `_cachedKdfParams`. Future _saveVaultV4({ required _Slot slot, required Uint8List salt, required Uint8List wrappedDek, required Uint8List wrapNonce, + required KdfParams params, }) async { if (_key == null || _key!.length != 32) { throw StateError('v4 save requires a 32-byte finalKey'); @@ -84,7 +100,7 @@ extension VaultStorage on VaultService { // Keystore (elle reçoit `wrappedDek` / `wrapNonce` déjà calculés), donc // l'étiquette suffit ici pour l'AAD comme pour le champ écrit. final label = _fileLabelFor(slot); - final aad = _aadV4(label); + final aad = _aadV4(label, params); final plainText = utf8.encode( jsonEncode(_entries.map((e) => e.toJson()).toList()), @@ -138,9 +154,13 @@ extension VaultStorage on VaultService { 'version': VaultService._currentVersion, 'kdf': { 'algo': 'argon2id', - 'm': VaultService._argon2M, - 't': VaultService._argon2T, - 'p': VaultService._argon2P, + // AUDIT 2026-08-03 — on écrit les paramètres REELLEMENT employés, pas + // les constantes de compilation. Ces champs sont désormais relus au + // déverrouillage : ils doivent décrire ce coffre-ci, pas la valeur + // recommandée du moment. + 'm': params.memoryKiB, + 't': params.iterations, + 'p': params.parallelism, 'salt': base64Encode(salt), }, 'kek': { @@ -173,5 +193,15 @@ extension VaultStorage on VaultService { _cachedSalt = Uint8List.fromList(salt); _cachedWrappedDek = Uint8List.fromList(wrappedDek); _cachedWrapNonce = Uint8List.fromList(wrapNonce); + _cachedKdfParams = params; + // AUDIT 2026-08-03 — si cette écriture vient de faire franchir un barreau + // au coffre principal, le leurre FACTICE reste en arrière et les deux + // fichiers deviennent distinguables à la simple taille, définitivement. + // On le régénère sur le bon barreau. Ne fait rien dans le cas courant. + await _realignDummyDecoyIfSmaller( + writtenSlot: slot, + writtenPlainLen: ptBytes.length, + otherPlainLen: otherLen, + ); } } diff --git a/lib/services/vault_unlock.dart b/lib/services/vault_unlock.dart index 814b5fc..be9c5e4 100644 --- a/lib/services/vault_unlock.dart +++ b/lib/services/vault_unlock.dart @@ -177,15 +177,9 @@ extension VaultUnlock on VaultService { // coffre leurre — ce qui briserait le déni plausible. // Mêmes paramètres (m=19 MiB, t=2, p=1) que les vrais slots. // P2-fix v2.3.2 : password constant, indépendant du masterPassword. - // Évite le timing oracle marginal sur passwords très courts (le coût - // Argon2id varie linéairement avec la longueur du password). Le but - // ici est juste de consommer ~1 Argon2id, pas de hasher quelque chose. - final dummySalt = SecretBytes.randomBytes(32); - final dummyOut = await KdfService.argon2id( - password: 'pt_dummy_noop_v2', - salt: dummySalt, - ); - SecretBytes.wipe(dummyOut); + // AUDIT 2026-08-03 — corps factorisé dans `_consumeDummyArgon2` + // (il en existait trois copies). + await _consumeDummyArgon2(); return UnlockResult.wrongPassword; } @@ -228,6 +222,10 @@ extension VaultUnlock on VaultService { // secondaire (`_unlockInternal` est le principal) mais doit migrer // aussi, sinon un coffre ouvert par ici garderait son étiquette héritée. await _migrateFileLabelIfLegacy(slot); + // AUDIT 2026-08-03 — même raison : ce chemin doit réaligner le + // rembourrage comme le fait le chemin principal, sinon un coffre ouvert + // par ici resterait sur un barreau inférieur à celui de son voisin. + await _realignPaddingIfNeeded(slot); return UnlockResult.success; } @@ -318,6 +316,7 @@ extension VaultUnlock on VaultService { Uint8List salt, Uint8List wrappedDek, Uint8List wrapNonce, + KdfParams params, })? > _v4Unlock({ @@ -325,18 +324,48 @@ extension VaultUnlock on VaultService { required String password, required Map raw, }) async { - final kdf = raw['kdf']; - final kek = raw['kek']; - if (kdf is! Map || kek is! Map) return null; - final salt = base64Decode(kdf['salt'] as String); - final wrappedDek = base64Decode(kek['wrappedDek'] as String); - final wrapNonce = base64Decode(kek['wrapNonce'] as String); + // AUDIT 2026-08-03 — l'en-tête est décodé sous garde. + // + // Ces `base64Decode` et ces transtypages étaient placés AVANT le `try` + // ci-dessous : sur un fichier malformé (champ absent, base64 invalide, + // type inattendu) ils levaient une exception qui remontait jusqu'à + // `unlock()`, laquelle ne comporte qu'un `finally`. Deux conséquences : + // • côté écran, l'indicateur de progression restait affiché + // indéfiniment, sans message ni issue ; + // • pire, comme les deux emplacements sont parcourus dans la même + // boucle, un LEURRE corrompu faisait échouer l'ouverture du coffre + // PRINCIPAL, pourtant parfaitement lisible. + // Le contrat de cette fonction est déjà « rend `null` en cas d'échec » : + // on l'honore aussi pour un en-tête illisible. + final Uint8List salt; + final Uint8List wrappedDek; + final Uint8List wrapNonce; + final String fileLabel; + final KdfParams params; + try { + final kdf = raw['kdf']; + final kek = raw['kek']; + if (kdf is! Map || kek is! Map) return null; + // AUDIT 2026-08-03 — les paramètres Argon2id viennent du FICHIER. + // Ils y étaient écrits depuis la v4 mais n'avaient jamais été relus : + // la dérivation prenait les constantes de compilation. Voir + // `KdfParams.fromFileOrNull` pour ce que cela impliquait le jour où + // ces constantes bougeraient. `null` = valeurs hors bornes → on refuse. + final p = KdfParams.fromFileOrNull(kdf); + if (p == null) return null; + params = p; + salt = base64Decode(kdf['salt'] as String); + wrappedDek = base64Decode(kek['wrappedDek'] as String); + wrapNonce = base64Decode(kek['wrapNonce'] as String); + fileLabel = kek['alias'] as String; + } catch (_) { + return null; + } // SEC F18 v2.5.4 — `fileLabel` est l'étiquette NEUTRE lue dans le fichier. // Elle sert à l'AAD et au contrôle anti-copie, JAMAIS à adresser le // Keystore : cet adressage passe par `_aliasFor(slot)`, qui ne quitte // jamais la RAM. Confondre les deux réintroduirait le mot « decoy » dans - // le fichier. - final fileLabel = kek['alias'] as String; + // le fichier. (Lu ci-dessus, sous la garde d'en-tête.) // A3 v2.3.8 — défense en profondeur : refuse les blobs cross-slot. // Avant : un attaquant root copiant pt_vault_decoy.enc sur le chemin @@ -357,7 +386,11 @@ extension VaultUnlock on VaultService { Uint8List? hwSecret; Uint8List? finalKey; try { - pwHash = await KdfService.argon2id(password: password, salt: salt); + pwHash = await KdfService.argon2id( + password: password, + salt: salt, + params: params, + ); try { // SEC F18 v2.5.4 — adressage du Keystore par `_aliasFor(slot)` et NON // par l'étiquette lue dans le fichier. C'est ce découplage qui permet @@ -376,7 +409,7 @@ extension VaultUnlock on VaultService { pwHash: pwHash, hwSecret: hwSecret, ); - final entries = await _decryptVaultV4(raw, finalKey); + final entries = await _decryptVaultV4(raw, finalKey, params); if (entries == null) { SecretBytes.wipe(finalKey); return null; @@ -396,6 +429,9 @@ extension VaultUnlock on VaultService { salt: Uint8List.fromList(salt), wrappedDek: Uint8List.fromList(wrappedDek), wrapNonce: Uint8List.fromList(wrapNonce), + // Les paramètres qui ont produit cette clé. L'appelant DOIT les + // conserver auprès du sel : toute réécriture du coffre les reporte. + params: params, ); } finally { if (pwHash != null) SecretBytes.wipe(pwHash); @@ -411,8 +447,12 @@ extension VaultUnlock on VaultService { // qui tape password puis fingerprint avant que le 1er unlock complète // déclenchait 2 paths en parallèle (`_key`, `_entries` mutables) → // corruption possible. Refus immédiat si un unlock concurrent tourne. + // + // AUDIT 2026-08-03 — rend `busy` et non plus `wrongPassword` : le cas + // nominal ici est justement « l'utilisateur a saisi son mot de passe puis + // posé son doigt », et lui répondre « échec biométrique » était faux. if (_unlockGate != null) { - return UnlockResult.wrongPassword; + return UnlockResult.busy; } final gate = _unlockGate = Completer(); try { @@ -482,26 +522,92 @@ extension VaultUnlock on VaultService { // v4 : the cached _key IS the 32-byte finalKey, no Argon2id / KEK // unwrap needed. The GCM tag binds the AAD so wrong-key fails closed. // v2.2.0 : `_decryptVaultV4` est pur. On assigne ici en cas de succès. - final entries = await _decryptVaultV4(raw, _key!); - if (entries == null) { + // + // AUDIT 2026-08-03 — les paramètres viennent du fichier ici aussi. + // Ce chemin ne dérive rien (la clé est déjà en cache), mais il en a + // besoin pour reconstruire l'AAD à l'identique, sans quoi l'étiquette + // GCM ne se vérifierait pas. + final kdf = raw['kdf']; + final params = kdf is Map ? KdfParams.fromFileOrNull(kdf) : null; + if (params == null) { ok = false; } else { - _entries = entries; - _isOpen = true; - ok = true; + final entries = await _decryptVaultV4(raw, _key!, params); + if (entries == null) { + ok = false; + } else { + _entries = entries; + _isOpen = true; + // AUDIT 2026-08-03 — le cache méta est peuplé ICI AUSSI. + // + // Ce chemin ne le remplissait pas, et c'est ce qui le privait + // silencieusement de deux traitements de fin de déverrouillage : + // • `_migrateFileLabelIfLegacy` (SEC F18), qui renonce quand le + // cache est vide — un coffre antérieur à la v2.5.4 gardait donc + // le mot « decoy » en clair sur le disque INDÉFINIMENT dès lors + // que son propriétaire n'ouvre qu'à l'empreinte, ce qui est + // précisément l'usage courant ; + // • le réalignement du rembourrage ajouté par cet audit. + // Chaque modification d'entrée repassait en outre par la relecture + // complète du fichier, que QW2 v2.4.0 avait justement supprimée. + _cachedKdfParams = params; + final kek = raw['kek']; + if (kek is Map) { + final saltB64 = kdf['salt']; + final wrappedB64 = kek['wrappedDek']; + final nonceB64 = kek['wrapNonce']; + if (saltB64 is String && + wrappedB64 is String && + nonceB64 is String) { + if (_cachedSalt != null) { + SecretBytes.wipe(_cachedSalt!); + } + if (_cachedWrappedDek != null) { + SecretBytes.wipe(_cachedWrappedDek!); + } + if (_cachedWrapNonce != null) { + SecretBytes.wipe(_cachedWrapNonce!); + } + _cachedSalt = base64Decode(saltB64); + _cachedWrappedDek = base64Decode(wrappedB64); + _cachedWrapNonce = base64Decode(nonceB64); + } + } + ok = true; + } } } else { - ok = _decryptVaultV3(raw); + // AUDIT 2026-08-03 — cette branche appelait `_decryptVaultV3(raw)`. + // Elle ne pouvait PAS aboutir, et le commentaire qui l'accompagnait + // décrivait un mécanisme inexistant (« filet de sécurité pour le + // premier déverrouillage après mise à jour »). + // + // Démonstration : `_key` vient d'être validée à EXACTEMENT 32 octets + // une trentaine de lignes plus haut (F2 v2.4.4), tandis que + // `_decryptVaultV3` exige une clé de 64 octets — c'est la garde + // SEC F13 v2.5.2, qui existe précisément pour qu'une clé v4 de 32 + // octets ne puisse jamais ouvrir un fichier v3. L'appel rendait donc + // invariablement `false`. + // + // Le vrai filet de sécurité est ailleurs, et il fonctionne : une + // enveloppe biométrique d'avant la v4 fait 64 octets, elle est donc + // rejetée par le contrôle de longueur, l'enveloppe est supprimée et + // l'utilisateur reçoit `biometricInvalidated` avec la marche à suivre. + ok = false; } if (ok) { _activeSlot = _Slot.primary; await _onUnlockSuccess(); - // If the stored key was a v3 64-byte cache, force re-enrol after the - // upcoming v4 migration trip — but we only reach here if the vault is - // already v4 (v3 unlock-with-bio still works as a safety net for the - // first unlock after upgrade; the bio cache will be wiped on - // _migrateV3ToV4 path triggered by password unlock). + // AUDIT 2026-08-03 — mêmes traitements de fin d'ouverture que sur le + // chemin par mot de passe. Ils manquaient tous les deux ici, ce qui + // rendait SEC F18 inopérant pour quiconque n'ouvre qu'à l'empreinte. + await _migrateFileLabelIfLegacy(_Slot.primary); + await _realignPaddingIfNeeded(_Slot.primary); + // On n'arrive ici que sur un coffre déjà en v4 (cf. la branche `else` + // ci-dessus). Un coffre encore en v3 se migre au premier + // déverrouillage par mot de passe, qui invalide au passage l'enveloppe + // biométrique — l'utilisateur la réactive ensuite depuis Réglages. return UnlockResult.success; } _wipeKey(); diff --git a/test/kdf_params_from_file_test.dart b/test/kdf_params_from_file_test.dart new file mode 100644 index 0000000..f0489b3 --- /dev/null +++ b/test/kdf_params_from_file_test.dart @@ -0,0 +1,332 @@ +// AUDIT 2026-08-03 — les paramètres Argon2id doivent être RELUS dans le +// fichier, jamais repris des constantes de compilation. +// +// Défaut corrigé : `kdf.m` / `kdf.t` / `kdf.p` étaient écrits dans les trois +// formats (coffre v4, instantané héritier v2, sauvegarde `.ptbak` v3) et +// n'étaient jamais relus — sauf par `importEncrypted`, qui les lisait pour +// dériver la clé mais construisait l'AAD à partir des constantes. Le jour où +// `KdfParams.owaspMobile2024` serait relevé, tous les fichiers existants +// seraient devenus indéchiffrables, en silence, sous un message « mot de passe +// incorrect ». +// +// Ces tests échouent sur le code d'avant le correctif. C'est leur seule raison +// d'être : aucun test existant ne forgeait de paramètres VALIDES mais +// différents des constantes — ceux du `.ptbak` ne vérifiaient que le rejet de +// valeurs hors bornes. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pass_tech/models/entry.dart'; +import 'package:pass_tech/services/aead_service.dart'; +import 'package:pass_tech/services/import_export_service.dart'; +import 'package:pass_tech/services/kdf_service.dart'; + +/// Reconstruit l'AAD `.ptbak` v3 telle que le format la définit. +/// Volontairement recopiée ici plutôt qu'importée : ce test doit épingler le +/// format de fichier, pas suivre le code si quelqu'un le change. +List _ptbakAad(String saltB64, KdfParams p) => utf8.encode( + 'ptbak:v=3|kdf=argon2id' + '|m=${p.memoryKiB}|t=${p.iterations}|p=${p.parallelism}' + '|salt=$saltB64', +); + +/// Fabrique une sauvegarde `.ptbak` v3 chiffrée sous [params], sans passer par +/// `exportEncrypted` — qui, lui, emploie toujours la recommandation courante. +Future _buildPtbak({ + required List entries, + required String passphrase, + required KdfParams params, + Map? kdfOverride, +}) async { + final salt = Uint8List.fromList(List.generate(32, (i) => i * 7 % 256)); + final saltB64 = base64Encode(salt); + final key = await KdfService.argon2id( + password: passphrase, + salt: salt, + params: params, + ); + final plain = Uint8List.fromList( + utf8.encode(jsonEncode(entries.map((e) => e.toJson()).toList())), + ); + final res = await AeadService.encryptGcm( + key: key, + plaintext: plain, + aad: Uint8List.fromList(_ptbakAad(saltB64, params)), + ); + return jsonEncode({ + 'magic': 'PTBAK', + 'version': 3, + 'kdf': + kdfOverride ?? + { + 'algo': 'argon2id', + 'm': params.memoryKiB, + 't': params.iterations, + 'p': params.parallelism, + 'salt': saltB64, + }, + 'cipher': { + 'nonce': base64Encode(res.nonce), + 'data': base64Encode(res.cipherAndTag), + }, + }); +} + +Entry _entry(String title) => + Entry(title: title, category: 'Autres', password: 'motdepasse-$title'); + +void main() { + group('KdfParams.fromFileOrNull', () { + test('paramètres absents → repli sur la recommandation courante', () { + final p = KdfParams.fromFileOrNull({'salt': 'x'}); + expect(p, isNotNull); + expect(p!.memoryKiB, KdfParams.owaspMobile2024.memoryKiB); + expect(p.iterations, KdfParams.owaspMobile2024.iterations); + expect(p.parallelism, KdfParams.owaspMobile2024.parallelism); + }); + + test('paramètres valides mais DIFFÉRENTS des constantes → honorés', () { + // Le cœur du correctif : ce cas rendait auparavant les constantes. + final p = KdfParams.fromFileOrNull({ + 'm': 8192, + 't': 3, + 'p': 2, + }); + expect(p, isNotNull); + expect(p!.memoryKiB, 8192); + expect(p.iterations, 3); + expect(p.parallelism, 2); + expect(p.outLen, 32); + }); + + test('mémoire trop faible → refus', () { + expect( + KdfParams.fromFileOrNull({'m': 1024, 't': 2, 'p': 1}), + isNull, + ); + }); + + test('mémoire trop grande (épuisement RAM) → refus', () { + expect( + KdfParams.fromFileOrNull({ + 'm': 1024 * 1024 + 1, + 't': 2, + 'p': 1, + }), + isNull, + ); + }); + + test('itérations hors bornes → refus', () { + expect( + KdfParams.fromFileOrNull({'m': 19456, 't': 0, 'p': 1}), + isNull, + ); + expect( + KdfParams.fromFileOrNull({ + 'm': 19456, + 't': 17, + 'p': 1, + }), + isNull, + ); + }); + + test('parallélisme hors bornes → refus', () { + expect( + KdfParams.fromFileOrNull({'m': 19456, 't': 2, 'p': 0}), + isNull, + ); + expect( + KdfParams.fromFileOrNull({'m': 19456, 't': 2, 'p': 5}), + isNull, + ); + }); + + test('champ présent mais du mauvais type → refus', () { + expect( + KdfParams.fromFileOrNull({ + 'm': '19456', + 't': 2, + 'p': 1, + }), + isNull, + ); + }); + + test('champs partiellement présents → refus (fichier incohérent)', () { + expect( + KdfParams.fromFileOrNull({'m': 19456}), + isNull, + reason: + 'le repli ne vaut que si les TROIS sont absents ; un fichier qui ' + 'en annonce un seul est malformé et doit être refusé', + ); + }); + }); + + group('.ptbak v3 — l\'AAD suit les paramètres du FICHIER', () { + test( + 'une sauvegarde chiffrée sous des paramètres non standard se relit', + () async { + // Simule très exactement ce qui arrivera le jour où + // `owaspMobile2024` sera relevé : un fichier écrit avec d'autres + // paramètres que ceux compilés dans la version qui le relit. + const params = KdfParams( + memoryKiB: 8192, + iterations: 1, + parallelism: 1, + outLen: 32, + ); + expect( + params.memoryKiB == KdfParams.owaspMobile2024.memoryKiB, + isFalse, + reason: 'le test perdrait tout son sens si les valeurs coïncidaient', + ); + + final content = await _buildPtbak( + entries: [_entry('banque'), _entry('messagerie')], + passphrase: 'phrase-secrete-de-test', + params: params, + ); + + final restored = await ImportExportService.importEncrypted( + content, + 'phrase-secrete-de-test', + ); + + // Avant le correctif : `null`. La clé était bien dérivée avec les + // paramètres du fichier, mais l'AAD était bâtie sur les constantes, + // donc l'étiquette AES-GCM ne se vérifiait pas — et l'utilisateur + // lisait « phrase secrète incorrecte » sur une sauvegarde parfaitement + // valide. + expect(restored, isNotNull); + expect(restored!.length, 2); + expect( + restored.map((e) => e.title), + containsAll(['banque', 'messagerie']), + ); + expect(restored.first.password, 'motdepasse-banque'); + }, + ); + + test('mauvaise phrase secrète → refus (fail-closed inchangé)', () async { + const params = KdfParams( + memoryKiB: 8192, + iterations: 1, + parallelism: 1, + outLen: 32, + ); + final content = await _buildPtbak( + entries: [_entry('banque')], + passphrase: 'la-bonne-phrase', + params: params, + ); + expect( + await ImportExportService.importEncrypted(content, 'la-mauvaise'), + isNull, + ); + }); + + test( + 'paramètres falsifiés dans le fichier → refus, pas de déchiffrement', + () async { + const params = KdfParams( + memoryKiB: 8192, + iterations: 1, + parallelism: 1, + outLen: 32, + ); + final salt = base64Encode( + Uint8List.fromList(List.generate(32, (i) => i * 7 % 256)), + ); + // Le fichier est chiffré sous m=8192 mais en annonce 16384 : la clé + // dérivée ET l'AAD divergent tous les deux, l'étiquette ne peut pas + // se vérifier. C'est le comportement voulu — un attaquant ne gagne + // rien à réécrire ces champs, il ne fait que casser son propre + // fichier. + final content = await _buildPtbak( + entries: [_entry('banque')], + passphrase: 'phrase-secrete-de-test', + params: params, + kdfOverride: { + 'algo': 'argon2id', + 'm': 16384, + 't': 1, + 'p': 1, + 'salt': salt, + }, + ); + expect( + await ImportExportService.importEncrypted( + content, + 'phrase-secrete-de-test', + ), + isNull, + ); + }, + ); + + test('paramètres hors bornes → refus AVANT tout calcul Argon2id', () async { + const params = KdfParams( + memoryKiB: 8192, + iterations: 1, + parallelism: 1, + outLen: 32, + ); + final salt = base64Encode( + Uint8List.fromList(List.generate(32, (i) => i * 7 % 256)), + ); + final content = await _buildPtbak( + entries: [_entry('banque')], + passphrase: 'phrase-secrete-de-test', + params: params, + kdfOverride: { + 'algo': 'argon2id', + 'm': 4 * 1024 * 1024, // 4 Gio : épuiserait la mémoire de l'appareil + 't': 1, + 'p': 1, + 'salt': salt, + }, + ); + expect( + await ImportExportService.importEncrypted( + content, + 'phrase-secrete-de-test', + ), + isNull, + ); + }); + }); + + group('.ptbak v3 — métadonnées en clair', () { + test('ni le nombre d\'entrées ni la date ne sont écrits', () async { + final content = await ImportExportService.exportEncrypted([ + _entry('banque'), + _entry('messagerie'), + _entry('impots'), + ], 'phrase-secrete-de-test'); + final json = jsonDecode(content) as Map; + + // AUDIT 2026-08-03 — `count` et `exportedAt` étaient écrits en clair, + // hors chiffrement et hors AAD, dans le seul fichier de l'application + // destiné à quitter l'appareil. Ils annonçaient à qui mettait la main + // dessus combien d'identifiants la personne détient et quand elle a fait + // sa sauvegarde, sans qu'aucun code ne les relise jamais. + expect(json.containsKey('count'), isFalse); + expect(json.containsKey('exportedAt'), isFalse); + + // Ce qui reste doit suffire à relire le fichier. + expect(json['magic'], 'PTBAK'); + expect(json['version'], 3); + final restored = await ImportExportService.importEncrypted( + content, + 'phrase-secrete-de-test', + ); + expect(restored, isNotNull); + expect(restored!.length, 3); + }); + }); +} From 78a05762af0c167c8d1277d05ff63bfa39782bf8 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 21:44:55 +0200 Subject: [PATCH 02/34] fix(securite): 5 findings Gemini + retrait total de Google ML Kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUDIT CROISE GEMINI — 6 findings, 5 confirmees apres relecture du code, 1 partiellement fausse (FLAG_SECURE notes : les instantanes systeme sont deja proteges par SEC F3, seul l'arbitrage Knox assume subsiste). CRITIQUE - Changement de mot de passe maitre : `_key` passait a la nouvelle cle AVANT l'ecriture. Si `_saveVaultV4` echouait (disque plein, E/S), la session gardait la cle neuve face a un fichier reste sous l'ancienne, avec les metadonnees anciennes en cache. Le premier ajout d'entree scellait l'incoherence : NI l'ancien NI le nouveau mot de passe n'ouvraient plus le coffre. Restauration de la cle precedente, avec point de bascule sur le renommage atomique et non sur l'ecriture du sel (qui n'est pas porteuse en v4). ELEVE - `Stopwatch` (CLOCK_MONOTONIC) ne compte PAS la veille profonde : apres deux heures ecran eteint, le verrouillage automatique ne se declenchait pas. On retient desormais le plus grand des deux temps ecoules, `elapsedRealtime` (via le canal RASP) et `Stopwatch` conserve en second temoin synchrone. - `_passwordMatchesPrimaryInternal` restaurait l'etat du coffre dans son `finally` SANS CONDITION : un `lock()` concurrent — mise en arriere-plan, minuterie, ou MODE PANIQUE — etait annule et le coffre rouvert en memoire. Compteur `_lockGeneration` + resultat transitant par une variable (un `finally` ne peut pas corriger une valeur deja rendue). - `pass_tech_export.json` (tous les mots de passe EN CLAIR) survivait dans le cache si le processus etait tue pendant le selecteur de partage. Trou de mon propre correctif SEC F8 du matin : la purge ne couvrait que `share_plus/`. - FilePicker recopie le fichier importe dans le cache et ne le supprimait jamais : un export Bitwarden en clair y restait a demeure. Dechiquetage + `clearTemporaryFiles()`, sous garde stricte verifiant que le chemin est bien une copie en cache — effacer le document source de l'utilisateur serait irreparable. RETRAIT DE GOOGLE ML KIT `mobile_scanner` entrainait Play Services, ML Kit et le transport de telemetrie `datatransport`, d'ou une permission ACCESS_NETWORK_STATE que le depot ne declarait nulle part. Une pile Google fermee dans une app qui annonce « aucun tracker ». La dependance est retiree. Le scan par camera est remplace par l'extraction du secret depuis une URI `otpauth://` collee dans le champ 2FA — la plupart des services l'affichent en clair sous le QR code. Pas de bibliotheque tierce, pas de lecture programmee du presse-papier (bloquee par Knox de toute facon). PREUVE sur le manifeste FUSIONNE : les permissions passent de INTERNET, USE_BIOMETRIC, USE_FINGERPRINT, CAMERA, ACCESS_NETWORK_STATE a INTERNET, USE_BIOMETRIC, USE_FINGERPRINT Zero occurrence de gms / mlkit / datatransport / firebase. Propage : PRIVACY.fr.md + PRIVACY.md (§6, §10), THIRD_PARTY_NOTICES.md, THREAT_MODEL.md §5.2, android/expected-permissions.txt, anti-fonctionnalite NonFreeDep retiree de fdroiddata, 21 chaines l10n orphelines supprimees, commentaires de build qui citaient la dependance disparue. Tests : 139, tous verts. flutter analyze : 0 issue. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 27 ++- .github/workflows/promesses.yml | 150 +++++++++++++++ PRIVACY.fr.md | 45 ++--- PRIVACY.md | 42 ++-- THIRD_PARTY_NOTICES.md | 39 ++-- THREAT_MODEL.md | 233 +++++++++++++++++++++++ android/app/build.gradle.kts | 4 +- android/app/proguard-rules.pro | 1 - android/app/src/main/AndroidManifest.xml | 33 +++- android/build.gradle.kts | 12 +- android/expected-permissions.txt | 58 ++++++ lib/l10n/app_en.arb | 11 -- lib/l10n/app_fr.arb | 10 - lib/main.dart | 56 +++++- lib/screens/entry_edit_screen.dart | 60 +++--- lib/screens/qr_scanner_screen.dart | 166 ---------------- lib/screens/settings_screen.dart | 104 ++++++++-- lib/services/vault_service.dart | 21 ++ lib/services/vault_setup.dart | 49 +++++ lib/services/vault_unlock.dart | 84 +++++--- pubspec.lock | 10 +- pubspec.yaml | 1 - 22 files changed, 864 insertions(+), 352 deletions(-) create mode 100644 .github/workflows/promesses.yml create mode 100644 THREAT_MODEL.md create mode 100644 android/expected-permissions.txt delete mode 100644 lib/screens/qr_scanner_screen.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 932882b..b644473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,13 +51,36 @@ jobs: working-directory: pass_tech run: flutter pub get + # AUDIT 2026-08-03 — `--no-fatal-infos` remplacé par le durcissement de la + # doctrine (`ci-et-chaine-de-publication.md` §4.1). Vérifié en local avant + # d'être posé ici : l'analyse rend « No issues found » sur ce dépôt, donc + # le durcissement ne coûte RIEN aujourd'hui et empêche la dérive demain. - name: Analyze working-directory: pass_tech - run: flutter analyze --no-fatal-infos + run: flutter analyze --fatal-infos --fatal-warnings + # AUDIT 2026-08-03 — nouveau contrôle. `tool/generate_icon.dart` n'était + # pas formaté au moment de l'ajout : sans cette barrière, le dépôt dérive + # fichier par fichier et les diffs de revue se noient dans du reformatage. + - name: Format + working-directory: pass_tech + run: dart format --output=none --set-exit-if-changed . + + # ⚠️ AUDIT 2026-08-03 — le `|| echo "No tests yet"` a été RETIRÉ. + # + # Il faisait passer le job au vert QUOI QU'IL ARRIVE : un test en échec + # produisait exactement la même pastille verte qu'une suite intacte. Sur + # un gestionnaire de mots de passe dont la suite couvre le round-trip + # AES-GCM, la migration v3→v4 et le rembourrage anti-corrélation, c'est + # la garantie la plus coûteuse du dépôt qui était neutralisée — et + # l'écriture même du garde-fou (« pas encore de tests ») avait cessé + # d'être vraie : il y en a 139. + # + # Une CI qui ne peut pas échouer ne teste rien ; elle rassure, ce qui est + # pire que pas de CI du tout. - name: Run tests working-directory: pass_tech - run: flutter test || echo "No tests yet" + run: flutter test # Build debug APK pour valider que la compilation passe end-to-end # (pas besoin de keystore release en CI). diff --git a/.github/workflows/promesses.yml b/.github/workflows/promesses.yml new file mode 100644 index 0000000..2138ff8 --- /dev/null +++ b/.github/workflows/promesses.yml @@ -0,0 +1,150 @@ +name: Promesses + +# AUDIT 2026-08-03 — vérifie sur l'ARTEFACT ce que l'application promet dans sa +# politique de confidentialité. Aucun autre mécanisme du dépôt ne couvre ça : +# `ci.yml` analyse le source, `security.yml` scanne les CVE des dépendances. +# Ni l'un ni l'autre ne regarde ce que le binaire contient RÉELLEMENT. +# +# Ce workflow existe parce que le manifeste FUSIONNÉ de la release contenait une +# permission (`ACCESS_NETWORK_STATE`) absente du dépôt, plus les composants de +# télémétrie de Google, sans que personne le sache. Une promesse publique se +# vérifie à chaque commit — pas au moment de couper la release, quand la +# dépendance fautive a déjà trois semaines. + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: '0 5 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + artefact: + name: Permissions + trackers sur l'APK release + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Java 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Pub get + run: flutter pub get + + # Le build release REFUSE de signer avec la clé de debug depuis SEC F15 + # v2.5.2 — c'est volontaire, et il ne faut surtout pas l'assouplir. On + # fabrique donc une clé JETABLE, valable un jour, qui ne quitte jamais le + # runner. + # + # ⚠️ L'APK produit ici n'est PAS publiable : il ne porte pas le certificat + # de Files Tech, donc il ne peut pas mettre à jour une installation + # existante. Il n'existe que pour être analysé, et n'est jamais téléversé. + - name: Clé de signature jetable (analyse uniquement) + run: | + keytool -genkeypair -v -keystore android/ci-throwaway.jks \ + -storepass ci-only -keypass ci-only -alias ci \ + -keyalg RSA -keysize 2048 -validity 1 -dname "CN=CI,O=throwaway" + cat > android/key.properties <<'EOF' + storeFile=ci-throwaway.jks + storePassword=ci-only + keyPassword=ci-only + keyAlias=ci + EOF + + # Release et non debug : c'est R8 qui décide de ce qui reste réellement + # dans le binaire distribué. Analyser un APK de debug sur-détecterait, + # puisque rien n'y est élagué. + - name: Build APK release (arm64) + run: flutter build apk --release --target-platform android-arm64 + + - name: Localiser aapt + id: sdk + run: | + AAPT=$(find "$ANDROID_SDK_ROOT/build-tools" -name aapt -type f | sort -V | tail -1) + echo "aapt=$AAPT" >> "$GITHUB_OUTPUT" + "$AAPT" version + + # ── Contrôle 1 : la surface de permissions n'a pas grandi ────────────── + - name: Permissions conformes à expected-permissions.txt + run: | + APK=$(find build -name '*release*.apk' | head -1) + echo "APK analysé : $APK" + + "${{ steps.sdk.outputs.aapt }}" dump permissions "$APK" \ + | grep -oP "(?<=uses-permission: name=')[^']+" \ + | sort -u > /tmp/actual.txt + + # La permission de réception dynamique est générée par Flutter à + # partir de l'applicationId : elle est propre à l'app, de niveau + # signature, et n'élargit aucune surface externe. + grep -v 'DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION' /tmp/actual.txt \ + > /tmp/actual-filtre.txt + + grep -v '^\s*#' android/expected-permissions.txt \ + | grep -v '^\s*$' | sort -u > /tmp/expected.txt + + echo "── Attendues ──"; cat /tmp/expected.txt + echo "── Réelles ──"; cat /tmp/actual-filtre.txt + + if ! diff -u /tmp/expected.txt /tmp/actual-filtre.txt; then + echo "" + echo "ECHEC : la liste des permissions de l'APK ne correspond plus." + echo " '+' = permission apparue et non déclarée. Trouver la" + echo " dépendance responsable avant toute chose :" + echo " grep -rn build/app/intermediates/manifest_merge_blame_file/" + echo " '-' = permission disparue : mettre à jour le fichier de" + echo " référence ET PRIVACY.md §10." + exit 1 + fi + echo "OK : surface de permissions inchangée." + + # ── Contrôle 2 : aucun tracker connu ────────────────────────────────── + # Le site annonce « aucun tracker » pour les huit applications, et + # App Manager Tech revendique une détection de trackers du même type. + # Un tracker signalé dans un binaire Files Tech par l'outil que les + # utilisateurs méfiants emploient eux-mêmes serait sans commune mesure + # avec le coût de ce contrôle. + - name: Aucun tracker connu (Exodus Privacy) + run: | + APK=$(find build -name '*release*.apk' | head -1) + docker run --rm -v "$PWD:/w" exodusprivacy/exodus-standalone \ + -j /w/exodus.json "/w/$APK" || true + COUNT=$(jq '.trackers | length' exodus.json) + echo "Trackers détectés : $COUNT" + jq -r '.trackers[]?.name' exodus.json || true + if [ "$COUNT" -ne 0 ]; then + echo "ECHEC : $COUNT tracker(s) dans l'APK release." + exit 1 + fi + echo "OK : aucun tracker." + + - name: Rapport Exodus + if: always() + uses: actions/upload-artifact@v7 + with: + name: exodus-${{ github.sha }} + path: exodus.json + if-no-files-found: ignore + retention-days: 30 + + # La clé jetable et le key.properties généré ne doivent jamais survivre à + # l'exécution, même si une étape précédente a échoué. + - name: Purge de la clé jetable + if: always() + run: rm -f android/ci-throwaway.jks android/key.properties diff --git a/PRIVACY.fr.md b/PRIVACY.fr.md index e28b006..8988ea3 100644 --- a/PRIVACY.fr.md +++ b/PRIVACY.fr.md @@ -65,29 +65,23 @@ La présente Politique de confidentialité explique comment l'application **Pass - Network Security Config refuse le HTTP en clair et les autorités utilisateur en release. - **Nous n'opérons aucun serveur.** Aucune donnée n'est envoyée au développeur, aucun analytics maison, aucun rapport de crash. -### Bibliothèque tierce : Google ML Kit (scan de QR code) - -Par souci d'exactitude, une troisième pile réseau est présente dans l'application, -et elle ne vient pas de notre code : - -- Le scan de QR code (utilisé pour importer un secret d'authentification à deux - facteurs) repose sur la bibliothèque `mobile_scanner`, qui s'appuie sur - **Google ML Kit**. Celle-ci embarque à son tour `play-services-basement`, - `play-services-base` et le composant de transport - `com.google.android.datatransport` (dit « CCT »), susceptible de remonter à - Google des statistiques d'utilisation de la bibliothèque. -- C'est également l'origine de la permission **`ACCESS_NETWORK_STATE`** visible - dans la fiche de l'application : elle est ajoutée automatiquement par cette - dépendance, et non déclarée par Pass Tech. -- **Ce composant n'a jamais accès au contenu de votre coffre.** Il ne voit que - l'image de la caméra au moment où vous scannez un QR code, et uniquement à ce - moment-là. Vos identifiants, votre mot de passe maître et vos clés ne - transitent par aucune bibliothèque Google. -- Si ce point vous importe, n'utilisez pas le scan de QR code : le secret - d'authentification à deux facteurs peut toujours être **saisi à la main**, et - toutes les autres fonctions de l'application s'en passent totalement. - -Cette dépendance est signalée par l'anti-fonctionnalité `NonFreeDep` sur F-Droid. +### Aucune bibliothèque Google + +L'application ne contient **aucun composant Google** : ni Play Services, ni +ML Kit, ni Firebase, ni transport de télémétrie. + +Ce n'était pas le cas jusqu'au 2026-08-03. Le scan de QR code reposait alors sur +`mobile_scanner`, bâti sur **Google ML Kit**, qui entraînait à sa suite +`play-services-base`, `play-services-basement` et le composant de transport +`com.google.android.datatransport`. Cette dépendance ajoutait au passage une +permission `ACCESS_NETWORK_STATE` **que le dépôt ne déclarait nulle part**. + +Le scan par caméra a donc été retiré, et avec lui les permissions `CAMERA` et +`ACCESS_NETWORK_STATE`. Pour ajouter un secret d'authentification à deux +facteurs, **collez l'URI `otpauth://…`** dans le champ prévu : l'application en +extrait le secret automatiquement. La plupart des services affichent cette URI +en toutes lettres sous le QR code, derrière un lien du type « impossible de +scanner ? ». Le secret peut aussi être saisi à la main. ## 7. Partage et transmission de données @@ -131,8 +125,9 @@ Voir [SECURITY.md](./SECURITY.md). | ------------------------------------ | ------------------------------------------------------------------------------------------------- | | `USE_BIOMETRIC` / `USE_FINGERPRINT` | Déverrouillage biométrique optionnel via Android BiometricPrompt. | | `INTERNET` | Vérification de mises à jour (GitHub Releases) et HIBP (k-anonymity, opt-in). | -| `CAMERA` | Scanner un QR code 2FA pour ajouter un secret TOTP. Flux caméra traité localement, jamais enregistré. | -| `ACCESS_NETWORK_STATE` | **Non déclarée par Pass Tech.** Ajoutée automatiquement par la bibliothèque de scan de QR code (Google ML Kit / `datatransport`). Voir §6. | + +`CAMERA` et `ACCESS_NETWORK_STATE` ont été **retirées le 2026-08-03** avec le +scan de QR code — voir §6. ## 11. Enfants diff --git a/PRIVACY.md b/PRIVACY.md index 0b9ac69..1c6c5eb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -65,27 +65,22 @@ This Privacy Policy explains how the **Pass Tech** application — a 100% local - Network Security Config rejects cleartext HTTP and user-installed CAs in release builds. - **We operate no server.** No data is sent to the developer, no in-house analytics, no crash reporting. -### Third-party library: Google ML Kit (QR code scanning) - -For the sake of accuracy, a third network stack is present in the app, and it does -not come from our code: - -- QR code scanning (used to import a two-factor authentication secret) relies on - the `mobile_scanner` library, which is built on **Google ML Kit**. That library - in turn bundles `play-services-basement`, `play-services-base` and the - `com.google.android.datatransport` transport component ("CCT"), which may report - library usage statistics back to Google. -- This is also where the **`ACCESS_NETWORK_STATE`** permission shown on the app - listing comes from: it is added automatically by that dependency, not declared - by Pass Tech. -- **This component never has access to your vault contents.** It only sees the - camera image at the moment you scan a QR code, and only then. Your credentials, - master password and keys never pass through any Google library. -- If this matters to you, do not use QR code scanning: a two-factor secret can - always be **typed in by hand**, and every other feature of the app works - entirely without it. - -This dependency is flagged with the `NonFreeDep` anti-feature on F-Droid. +### No Google libraries + +The app contains **no Google components**: no Play Services, no ML Kit, no +Firebase, no telemetry transport. + +This was not the case until 2026-08-03. QR code scanning then relied on +`mobile_scanner`, built on **Google ML Kit**, which pulled in +`play-services-base`, `play-services-basement` and the +`com.google.android.datatransport` transport component. That dependency also +added an `ACCESS_NETWORK_STATE` permission **declared nowhere in the repository**. + +Camera scanning was therefore removed, and with it the `CAMERA` and +`ACCESS_NETWORK_STATE` permissions. To add a two-factor secret, **paste the +`otpauth://…` URI** into the corresponding field: the app extracts the secret +automatically. Most services display that URI in plain text below the QR code, +behind a "can't scan?" link. The secret can also be typed by hand. ## 7. Sharing and data transmission @@ -128,8 +123,9 @@ See [SECURITY.md](./SECURITY.md) for the vulnerability disclosure policy. | ------------------------------------ | ------------------------------------------------------------------------------------------------ | | `USE_BIOMETRIC` / `USE_FINGERPRINT` | Optional biometric unlock via Android BiometricPrompt. | | `INTERNET` | Update check (GitHub Releases) and HIBP breach check (k-anonymity, opt-in). | -| `CAMERA` | Scan a 2FA QR code to add a TOTP secret. The camera feed is processed locally and never recorded. | -| `ACCESS_NETWORK_STATE` | **Not declared by Pass Tech.** Added automatically by the QR scanning library (Google ML Kit / `datatransport`). See §6. | + +`CAMERA` and `ACCESS_NETWORK_STATE` were **removed on 2026-08-03** along with QR +code scanning — see §6. ## 11. Children diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1b4ee8c..7f076e7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -23,7 +23,6 @@ Versions are those declared in `pubspec.yaml` for Pass Tech v2.0.2. | 11 | `intl` | ^0.20.2 | BSD-3-Clause | https://github.com/dart-lang/i18n | | 12 | `http` | ^1.2.0 | BSD-3-Clause | https://github.com/dart-lang/http | | 13 | `path_provider` | ^2.1.4 | BSD-3-Clause | https://github.com/flutter/packages | -| 14 | `mobile_scanner` | ^5.2.3 | BSD-3-Clause | https://github.com/juliansteenbakker/mobile_scanner | | 15 | `file_picker` | ^11.0.0 | MIT | https://github.com/miguelpruivo/flutter_file_picker | | 16 | `flutter_slidable` | ^4.0.3 | MIT | https://github.com/letsar/flutter_slidable | | 17 | `biometric_storage` | ^5.0.1 | MIT | https://github.com/authpass/biometric_storage | @@ -53,31 +52,27 @@ No custom cryptography is implemented in this application. ## Transitive Android dependencies (not declared in `pubspec.yaml`) -Audited 2026-08-03 against the **merged release manifest** -(`build/app/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml`). -Some Flutter plugins pull in native Android artifacts that never appear in -`pubspec.yaml`. The ones that matter here: - -| Pulled in by | Artifact | License | Note | -| ------------ | -------- | ------- | ---- | -| `mobile_scanner` | `com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.0` | Android Software Development Kit License (**proprietary**) | QR code scanning engine | -| `mobile_scanner` | `com.google.android.gms:play-services-base:18.1.0` | Android SDK License (**proprietary**) | | -| `mobile_scanner` | `com.google.android.gms:play-services-basement:18.1.0` | Android SDK License (**proprietary**) | | -| `mobile_scanner` | `com.google.mlkit:common:18.9.0`, `com.google.mlkit:vision-common:17.3.0` | Android SDK License (**proprietary**) | | -| `mobile_scanner` | `com.google.android.datatransport:transport-runtime:2.2.6`, `transport-backend-cct:2.3.3` | Apache-2.0 | Google telemetry transport ("CCT"). **Source of the `ACCESS_NETWORK_STATE` permission** and of the `JobInfoSchedulerService` / `AlarmManagerSchedulerBroadcastReceiver` components in the merged manifest. | -| `biometric_storage` | re-declares `android.permission.USE_FINGERPRINT` (no `maxSdkVersion`) | — | Required by `androidx.biometric` on API 24-27 | - -Consequences are documented for users in [`PRIVACY.md`](./PRIVACY.md) §6 and §10, -and declared to F-Droid via the `NonFreeDep` anti-feature. The QR scanner is the -**only** feature relying on this stack; a 2FA secret can always be typed in by -hand instead. +Audited 2026-08-03 against the **merged release manifest**. Some Flutter plugins +pull in native Android artifacts that never appear in `pubspec.yaml`. + +**As of 2026-08-03 the app contains no Google artifacts at all.** The previous +entry documented Google ML Kit, Play Services and `datatransport`, all pulled in +by `mobile_scanner` for QR code scanning; that dependency was removed. See +[`THREAT_MODEL.md`](./THREAT_MODEL.md) §5.2. + +The only remaining transitive manifest contribution: + +| Pulled in by | Contribution | Note | +| ------------ | ------------ | ---- | +| `biometric_storage` | re-declares `android.permission.USE_FINGERPRINT` (no `maxSdkVersion`) | Required by `androidx.biometric` on API 24-27 | + +The permission set is pinned in `android/expected-permissions.txt` and verified +against the built APK on every commit by `.github/workflows/promesses.yml`. To re-check after any dependency bump: ```bash -grep -E "gms|mlkit|datatransport" \ - build/app/intermediates/manifest_merge_blame_file/release/\ -processReleaseMainManifest/manifest-merger-blame-release-report.txt +grep -E "gms|mlkit|datatransport|firebase" build/app/intermediates/manifest_merge_blame_file/release/processReleaseMainManifest/manifest-merger-blame-release-report.txt ``` ## External services queried by the user diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000..6ca833d --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,233 @@ +# Pass Tech — modèle de menace + +**Version du document** : 1.0 — 2026-08-03 +**Version de l'application** : 2.5.4 +**Licence** : Apache 2.0 — [code source](https://github.com/gitubpatrice/pass_tech) + +> Ce document dit ce que Pass Tech protège, contre qui, **et ce qu'elle ne +> protège pas**. La seconde partie est la plus importante : un gestionnaire de +> mots de passe qui laisse croire à une protection qu'il n'offre pas est plus +> dangereux qu'un gestionnaire modeste. +> +> Tout ce qui suit est vérifiable dans le code. Quand une limite est +> structurelle, elle est signalée comme telle plutôt que minimisée. + +--- + +## 1. Ce qui est protégé + +| Bien | Où il vit | Comment | +|---|---|---| +| Mots de passe, notes, cartes bancaires | `pt_vault_a.enc` | AES-GCM-256, clé jamais écrite sur disque | +| Mot de passe maître | nulle part | Jamais stocké ; seul son dérivé Argon2id sert à la clé | +| Clé de coffre | RAM uniquement | Effacée au verrouillage, à la mise en arrière-plan, à la panique | +| Secret matériel (`hwSecret`) | AndroidKeyStore (TEE/StrongBox) | Enveloppé par une clé qui ne quitte jamais le composant sécurisé | +| Existence d'un second coffre | — | Déni plausible : voir §4 | + +### Chaîne cryptographique + +``` +mot de passe maître ──Argon2id(m=19 MiB, t=2, p=1, sel 32 o)──> pwHash + │ +AndroidKeyStore (KEK, non extractible) ──unwrap──> hwSecret ───────┤ + ▼ + HKDF-SHA256(pwHash ‖ hwSecret, info="pt:v4") ──> clé finale + │ + AES-GCM-256 + AAD ─────┘ +``` + +**Ce que cette construction implique concrètement** : une copie du fichier de +coffre, prise sur un appareil et emportée ailleurs, est **inexploitable**. Le +`hwSecret` est scellé dans le composant sécurisé de CE téléphone. Un attaquant +qui vole le fichier doit aussi disposer du matériel, et même alors il lui faut +le mot de passe maître, à raison d'un essai par seconde environ. + +--- + +## 2. Adversaires considérés + +| # | Adversaire | Capacités | Couverture | +|---|---|---|---| +| A1 | **Personne qui trouve le téléphone verrouillé** | Accès physique, pas de déverrouillage | ✅ Complète | +| A2 | **Personne qui trouve le téléphone déverrouillé** | Accès à l'écran d'accueil | ✅ Verrouillage auto, FLAG_SECURE, presse-papier purgé | +| A3 | **Application malveillante installée** | Même appareil, bac à sable Android | ✅ Bonne — voir limites §5 | +| A4 | **Analyse post-mortem (forensic) sans root** | Copie de la partition de données | ✅ Complète | +| A5 | **Contrainte physique** (« donnez le mot de passe ») | Vous êtes présent et sous pression | 🟡 Partielle — voir §4 | +| A6 | **Attaquant avec root / Magisk** | Contrôle total de l'OS | ❌ Hors modèle — voir §5.1 | +| A7 | **Attaquant étatique ciblé** | 0-day, exploitation du TEE | ❌ Hors modèle | + +**La ligne est en A6.** C'est une limite honnête, partagée par tous les +gestionnaires de mots de passe : sur un appareil rooté, un attaquant lit la +mémoire du processus et intercepte la frappe. Aucun logiciel ne s'en protège. +L'application détecte le root et vous avertit, mais cette détection se contourne +(Magisk Hide) — elle vaut comme signal, pas comme barrière. + +--- + +## 3. Protections et leur portée exacte + +### 3.1 Force brute du mot de passe maître + +- **En ligne** (sur l'appareil) : verrouillage progressif après 5 échecs — + 30 s, 60 s, 5 min, 15 min, 30 min. +- Le compte à rebours est ancré sur `SystemClock.elapsedRealtime()`, **pas sur + l'horloge murale**. Avancer la date dans les Réglages ne le raccourcit pas ; + redémarrer non plus, et un redémarrage l'allonge même en pratique. +- **Hors ligne** (à partir d'une copie du fichier) : impossible sans l'appareil, + puisque le `hwSecret` manque. C'est l'apport principal du format v4. + +### 3.2 Captures d'écran et aperçu des applications récentes + +`FLAG_SECURE` est posé sur la fenêtre. Il est **temporairement relâché** sur un +seul écran : l'éditeur de note libre, pour permettre le collage depuis une autre +application. Ce relâchement est réarmé dès le passage en arrière-plan — c'est ce +qui évite qu'une note contenant des codes de récupération reste lisible dans la +vignette des applications récentes. + +### 3.3 Presse-papier + +Effacement automatique (30 s par défaut), immédiat à la mise en arrière-plan et +à la panique. Sur Android 13+, la valeur est marquée `EXTRA_IS_SENSITIVE` : elle +n'apparaît pas dans l'aperçu système. + +### 3.4 Anti-hameçonnage (optionnel, désactivé par défaut) + +Compare le domaine affiché par le navigateur au domaine de l'entrée, avant la +copie. **Ne lit que le domaine racine**, en mémoire volatile, pendant 15 s, et +uniquement sur 9 navigateurs reconnus. Aucune sortie réseau, aucun journal. + +**Limite** : repose sur un service d'accessibilité, que vous activez vous-même. +Il ne couvre pas les applications natives, ni un navigateur non reconnu — dans +ce cas le verdict est « inconnu » et l'application vous le dit, plutôt que de +laisser croire à une vérification. + +### 3.5 Sauvegardes + +- `allowBackup="false"` et `dataExtractionRules` excluent le coffre de toute + sauvegarde Android, cloud ou transfert d'appareil. +- Les sauvegardes `.ptbak` que vous créez sont chiffrées avec Argon2id + + AES-GCM sous une phrase secrète que vous choisissez. **Elles ne sont pas liées + au matériel** — c'est délibéré, pour rester restaurables sur un autre + téléphone, et cela signifie qu'une phrase faible est attaquable hors ligne. + Minimum imposé : 12 caractères. + +--- + +## 4. Déni plausible — portée et limites + +C'est la fonction la plus délicate de l'application, et celle dont les limites +doivent être les plus claires. + +### Ce qui est réellement garanti + +| Canal | Traitement | +|---|---| +| **Noms de fichiers** | `pt_vault_a.enc` / `pt_vault_b.enc` — aucun ne dit « leurre » | +| **Nombre de fichiers** | Toujours deux, dès la création du coffre : un leurre factice existe même si vous n'en avez pas configuré | +| **Taille des fichiers** | Rembourrage sur une échelle commune, réaligné automatiquement des deux côtés | +| **Contenu des fichiers** | Aucune étiquette distinctive ; le mot « decoy » n'apparaît plus sur le disque | +| **Temps de réponse** | Deux passes Argon2id systématiques, y compris quand le premier emplacement a déjà répondu | +| **Clés matérielles** | Les deux alias Keystore existent toujours, utilisés ou non | +| **Fonctionnalités** | Aucune différence de comportement entre les deux emplacements | + +### Ce qui n'est PAS garanti + +1. **Le camouflage ne couvre que le lanceur.** Le mode panique bascule l'icône + et le nom vers une calculatrice fonctionnelle. Mais `android:label` et + `android:icon` de l'application ne sont pas modifiables à l'exécution : + **Réglages → Applications continue d'afficher « Pass Tech »**, de même que le + sélecteur de partage et le gestionnaire de permissions. Le camouflage est + conçu pour le regard porté sur un écran d'accueil. Face à quelqu'un qui ouvre + les réglages, il ne tient pas. + +2. **La sortie du camouflage est publique.** Appui long de 2 s sur l'affichage + de la calculatrice. Le dépôt étant sous Apache 2.0, cette information est + accessible à tous. Elle protège d'une inspection distraite, pas d'un + adversaire qui sait déjà que l'appareil porte Pass Tech. + +3. **Le déni plausible ne résiste pas à un adversaire qui connaît + l'application.** Quelqu'un qui sait que Pass Tech crée systématiquement deux + emplacements sait aussi qu'il peut en exiger deux mots de passe. Le déni + fonctionne contre un examen générique, pas contre un examen informé. + +4. **Vous restez le maillon.** Si vous ouvrez le coffre principal devant + l'adversaire, aucune propriété cryptographique ne vous sauve. + +--- + +## 5. Hors du modèle de menace + +### 5.1 Appareil rooté ou compromis + +Lecture de la mémoire du processus, interception de la frappe, remplacement de +l'application. La clé de coffre est en clair en RAM tant que le coffre est +ouvert — **c'est inévitable**, il faut bien déchiffrer pour afficher. + +Mesures de réduction, sans prétention d'étanchéité : effacement actif des +tampons après usage, verrouillage automatique, détection du root avec +avertissement explicite. + +### 5.2 Bibliothèques tierces — plus aucun composant Google *(résolu)* + +Jusqu'au 2026-08-03, le scan de QR code reposait sur `mobile_scanner`, bâti sur +**Google ML Kit**, qui entraînait Play Services et le composant de télémétrie +`com.google.android.datatransport`. Ce composant n'a jamais eu accès au coffre — +il ne voyait que l'image de la caméra — mais il constituait une pile réseau non +maîtrisée dans une application dont c'est précisément l'argument, et il ajoutait +une permission `ACCESS_NETWORK_STATE` que le dépôt ne déclarait nulle part. + +**La dépendance a été retirée.** Le manifeste fusionné de la release ne déclare +plus que `INTERNET`, `USE_BIOMETRIC` et `USE_FINGERPRINT` ; `CAMERA` et +`ACCESS_NETWORK_STATE` ont disparu. La liste est figée dans +`android/expected-permissions.txt` et vérifiée **sur l'APK** à chaque commit +(`.github/workflows/promesses.yml`), avec un contrôle Exodus Privacy en prime : +toute permission ou tout traceur qui réapparaîtrait ferait échouer le build. + +Contrepartie assumée : l'ajout d'un secret 2FA se fait en collant l'URI +`otpauth://…` dans le champ prévu — l'application en extrait le secret — ou à la +main. La plupart des services affichent cette URI sous le QR code. + +### 5.3 Codes TOTP et horloge + +La RFC 6238 impose l'horloge murale — le serveur qui valide le code emploie la +même. Un attaquant root peut donc décaler la date pour rejouer un code dans la +fenêtre de ±30 s. **Toutes les autres décisions de sécurité** (verrouillage, +inactivité de l'héritage) utilisent une horloge monotone insensible à cela. + +### 5.4 Héritage + +`pt_heir.enc` est une copie complète du coffre chiffrée sous le **seul** mot de +passe héritier, sans liaison au matériel — l'héritier doit pouvoir la +déchiffrer sur un autre appareil. C'est donc le fichier le plus exposé à une +attaque hors ligne. Minimum 12 caractères imposé ; choisissez-le long. + +Le compte à rebours d'inactivité peut être **accéléré** par un attaquant root +qui avance l'horloge. Il lui faudra toujours le mot de passe héritier. + +--- + +## 6. Risques résiduels connus + +| # | Risque | Gravité | État | +|---|---|---|---| +| R1 | Clé de coffre en RAM pendant la session | Structurel | Accepté — effacements actifs | +| R2 | Mot de passe maître en `String` Dart non effaçable | Faible | Limite du langage | +| R3 | Enrôlement biométrique non invalidant sur certains constructeurs | Moyen | Avertissement dans Réglages ; le mot de passe maître reste requis pour réactiver | +| R4 | Pile Google via ML Kit | Moyen | Documenté, contournable en n'utilisant pas le scan | +| R5 | Camouflage partiel (§4.1) | Moyen | Documenté ; nécessiterait un second APK | +| R6 | Écrasement de fichier non garanti sur mémoire flash | Faible | Best-effort (copie sur écriture, nivellement d'usure) | + +--- + +## 7. Signaler une vulnérabilité + +**Ne pas ouvrir d'issue publique.** Un gestionnaire de mots de passe exige une +divulgation coordonnée. Procédure dans [`SECURITY.md`](./SECURITY.md). + +--- + +## Journal + +| Date | Révision | +|---|---| +| 2026-08-03 | Création. Rédigé à l'issue de l'audit du 2026-08-03 (16 correctifs), qui a rendu explicites les limites des §4.1, §5.2 et §5.4 — jusque-là dispersées dans des commentaires de code. | diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 869e0bc..9a46a9a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -40,7 +40,9 @@ android { versionCode = flutter.versionCode versionName = flutter.versionName // P2.1 v2.4.3 — réduction APK : seuls FR + EN embarqués (vs ~50 locales - // tirées par biometric_storage / mobile_scanner / Material). Gain ~3-6 Mo. + // tirées par biometric_storage / Material). Gain ~3-6 Mo. + // (2026-08-03 : `mobile_scanner` ne figure plus dans cette liste, la + // dépendance ayant été retirée avec le scan de QR code.) resourceConfigurations.addAll(listOf("en", "fr")) } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index baa7435..5c08649 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -7,7 +7,6 @@ # Plugins natifs utilisés -keep class com.it_nomads.fluttersecurestorage.** { *; } -keep class design.codeux.biometric_storage.** { *; } --keep class dev.steenbakker.mobile_scanner.** { *; } -keep class com.mr.flutter.plugin.filepicker.** { *; } # QW9 v2.4.0 — cryptography_flutter FFI : sans ces keeps, R8 peut élider diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c7e6a54..08681d4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,14 +28,31 @@ tools:node="remove" — ce serait une régression fonctionnelle réelle pour un bénéfice cosmétique inexistant. --> - - - + + with WidgetsBindingObserver { /// `DateTime.now()` suit l'horloge système : un attaquant root qui /// recule la date après pause empêche l'auto-lock de déclencher. /// `Stopwatch.elapsedMilliseconds` ne se laisse pas tromper. + /// + /// ⚠️ AUDIT 2026-08-03 (Gemini PT-001) — mais il se laisse ENDORMIR. + /// + /// Le `Stopwatch` de Dart s'appuie sur `CLOCK_MONOTONIC`, qui **cesse + /// d'avancer pendant la veille profonde** de l'appareil. Or c'est le cas + /// nominal : on repose son téléphone, l'écran s'éteint, Android entre en + /// Doze au bout de quelques minutes. Au retour, deux heures plus tard, le + /// chronomètre n'a compté que le temps d'éveil — souvent moins que le délai + /// de verrouillage automatique, réglé à 300 s par défaut. **Le coffre + /// restait donc ouvert.** Le correctif de la v2.3.8 protégeait du décalage + /// d'horloge et ouvrait cette brèche-là sans que personne le voie. + /// + /// La bonne source est `SystemClock.elapsedRealtime()`, qui compte le + /// sommeil profond — déjà exposée par `MonotonicClock.elapsedRealtimeMs()`, + /// introduite en v2.5.2 pour le verrouillage anti-force-brute. + /// + /// On conserve le `Stopwatch` en second témoin, pour deux raisons : il est + /// SYNCHRONE, donc capturable à l'instant exact du passage en arrière-plan + /// (l'appel de canal, lui, impose un `await` — c'est précisément le piège + /// qui avait affaibli SEC F3), et il reste disponible si la plateforme ne + /// répond pas. Au retour, on retient le **plus grand** des deux temps + /// écoulés : `elapsedRealtime` domine toujours, et en cas de réponse + /// aberrante du canal on verrouille plus tôt plutôt que plus tard. int? _pausedAtMonoMs; + int? _pausedAtBootMs; static final Stopwatch _stopwatch = Stopwatch()..start(); /// v2.5.0 (F9) — guard cache session sur `_checkForUpdate`. @@ -227,6 +252,12 @@ class _PassTechAppState extends State with WidgetsBindingObserver { // passage en arrière-plan, donc chaque `await` qui précède est une // fenêtre de capture. await SecureWindow.suspendRelaxForBackground(); + // AUDIT 2026-08-03 — ancre qui compte la veille profonde. Prise APRÈS + // l'horodatage synchrone et APRÈS le réarmement de FLAG_SECURE : c'est un + // aller-retour de canal, il ne doit précéder ni l'un ni l'autre. Le léger + // décalage est sans effet, puisque les deux ancres sont comparées à + // elles-mêmes au retour. + _pausedAtBootMs ??= await MonotonicClock.elapsedRealtimeMs(); // Wipe clipboard immediately on background : don't risk leaving secrets // in the clipboard if the OS kills the process before the timer fires. await ClipboardService.cancelAndClear(); @@ -238,7 +269,9 @@ class _PassTechAppState extends State with WidgetsBindingObserver { // SEC F3 v2.5.2 — rétablit la relaxation si un écran la demande encore. await SecureWindow.resumeRelaxAfterBackground(); final pausedMs = _pausedAtMonoMs; + final pausedBootMs = _pausedAtBootMs; _pausedAtMonoMs = null; + _pausedAtBootMs = null; // If vault was locked while paused (immediate option) → go to unlock if (!VaultService().isOpen) { @@ -265,8 +298,29 @@ class _PassTechAppState extends State with WidgetsBindingObserver { final prefs = await SharedPreferences.getInstance(); final lockSec = prefs.getInt('auto_lock_seconds') ?? 300; if (lockSec < 0) return; // never + + // AUDIT 2026-08-03 — temps réellement écoulé en arrière-plan. + // + // `elapsedRealtime` compte le sommeil profond, le `Stopwatch` non. On + // retient le PLUS GRAND des deux : en fonctionnement normal c'est + // toujours le premier, et si le canal rend une valeur aberrante ou + // indisponible, on retombe sur le second — donc on verrouille trop tôt, + // jamais trop tard. + var elapsedMs = pausedMs == null + ? 0 + : _stopwatch.elapsedMilliseconds - pausedMs; + if (pausedBootMs != null) { + final nowBootMs = await MonotonicClock.elapsedRealtimeMs(); + // Un recul signalerait un redémarrage — impossible ici, le processus + // n'y survit pas — ou une réponse incohérente : on l'ignore. + if (nowBootMs != null && nowBootMs >= pausedBootMs) { + final bootElapsed = nowBootMs - pausedBootMs; + if (bootElapsed > elapsedMs) elapsedMs = bootElapsed; + } + } + if (pausedMs != null && - _stopwatch.elapsedMilliseconds - pausedMs >= lockSec * 1000 && + elapsedMs >= lockSec * 1000 && VaultService().isOpen) { VaultService().lock(); _navigatorKey.currentState?.pushAndRemoveUntil( diff --git a/lib/screens/entry_edit_screen.dart b/lib/screens/entry_edit_screen.dart index ef65c20..08102e4 100644 --- a/lib/screens/entry_edit_screen.dart +++ b/lib/screens/entry_edit_screen.dart @@ -11,7 +11,6 @@ import '../services/vault_service.dart'; import '../utils/snack_utils.dart'; import '../widgets/password_text_field.dart'; import 'generator_screen.dart'; -import 'qr_scanner_screen.dart'; class EntryEditScreen extends StatefulWidget { final Entry? entry; @@ -229,28 +228,40 @@ class _EntryEditScreenState extends State { return raw.trim(); } - Future _scanQrForTotp() async { + /// Extrait le secret quand une URI `otpauth://` arrive dans le champ 2FA. + /// + /// AUDIT 2026-08-03 — remplace le scan de QR code par caméra, retiré avec la + /// dépendance `mobile_scanner` (Google ML Kit — voir `THREAT_MODEL.md` §5.2). + /// + /// La valeur du scan n'était pas la caméra en soi : c'était de n'avoir pas à + /// recopier une chaîne Base32 de 32 caractères à la main. Un QR code + /// d'authentification contient une URI + /// `otpauth://totp/Service:moi?secret=XXXX&issuer=Service`, et cette URI est + /// affichée en toutes lettres par la plupart des services, sous le QR code, + /// derrière un lien « impossible de scanner ? ». On la colle ici, l'app en + /// tire le `secret` : le confort est conservé, sans caméra, sans permission + /// et sans bibliothèque tierce. + /// + /// Fonctionne aussi avec un secret Base32 collé tel quel — dans ce cas + /// `_extractTotpSecret` rend la chaîne inchangée et rien ne se produit + /// visuellement. + /// + /// ⚠️ Le collage se fait DANS le champ, par le clavier. On ne lit jamais le + /// presse-papier par programme : `Clipboard.getData` est bloqué par Knox dès + /// qu'une fenêtre a porté FLAG_SECURE (limite constatée en v2.3.10), un + /// bouton « Coller » serait donc muet sur Samsung. + void _onTotpChanged(String value) { + if (_totpError != null) setState(() => _totpError = null); + final trimmed = value.trim(); + if (!trimmed.toLowerCase().startsWith('otpauth://')) return; + final secret = _extractTotpSecret(trimmed); + if (secret == null || secret.isEmpty || secret == trimmed) return; + if (TotpService.validate(secret) != null) return; final messenger = ScaffoldMessenger.of(context); final t = AppLocalizations.of(context); - final raw = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const QrScannerScreen()), - ); - if (raw == null || !mounted) return; - final secret = _extractTotpSecret(raw); - if (secret == null || secret.isEmpty) { - messenger.showSnackBar(SnackBar(content: Text(t.entryEditQrInvalid))); - return; - } - final err = TotpService.validate(secret); - if (err != null) { - messenger.showSnackBar( - SnackBar(content: Text(t.entryEditSecretInvalid(err))), - ); - return; - } setState(() { _totpCtrl.text = secret; + _totpCtrl.selection = TextSelection.collapsed(offset: secret.length); _totpError = null; }); messenger.showSnackBar(SnackBar(content: Text(t.entryEditSecretAdded))); @@ -479,16 +490,7 @@ class _EntryEditScreenState extends State { helperText: t.entryEditHelper2fa, errorText: _totpError, prefixIcon: const Icon(Icons.shield_outlined, size: 20), - onChanged: (_) { - if (_totpError != null) setState(() => _totpError = null); - }, - extraSuffixIcons: [ - IconButton( - icon: const Icon(Icons.qr_code_scanner, size: 20), - tooltip: t.entryEditTooltipScanQr, - onPressed: _scanQrForTotp, - ), - ], + onChanged: _onTotpChanged, ), const SizedBox(height: 16), diff --git a/lib/screens/qr_scanner_screen.dart b/lib/screens/qr_scanner_screen.dart deleted file mode 100644 index c14c9f9..0000000 --- a/lib/screens/qr_scanner_screen.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -import '../l10n/app_localizations.dart'; - -class QrScannerScreen extends StatefulWidget { - const QrScannerScreen({super.key}); - - @override - State createState() => _QrScannerScreenState(); -} - -class _QrScannerScreenState extends State - with WidgetsBindingObserver { - final MobileScannerController _controller = MobileScannerController( - formats: const [BarcodeFormat.qrCode], - detectionSpeed: DetectionSpeed.noDuplicates, - ); - bool _handled = false; - bool _torch = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _controller.dispose(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.paused: - case AppLifecycleState.inactive: - case AppLifecycleState.hidden: - _controller.stop(); - break; - case AppLifecycleState.resumed: - if (!_handled) _controller.start(); - break; - case AppLifecycleState.detached: - break; - } - } - - void _handleBarcode(BarcodeCapture capture) { - if (_handled) return; - for (final b in capture.barcodes) { - final raw = b.rawValue; - if (raw != null && raw.isNotEmpty) { - _handled = true; - Navigator.of(context).pop(raw); - break; - } - } - } - - @override - Widget build(BuildContext context) { - final t = AppLocalizations.of(context); - return Scaffold( - appBar: AppBar( - title: Text(t.qrScannerTitle), - backgroundColor: Colors.black, - foregroundColor: Colors.white, - actions: [ - IconButton( - icon: Icon(_torch ? Icons.flash_on : Icons.flash_off), - tooltip: _torch ? t.qrTorchOff : t.qrTorchOn, - onPressed: () { - _controller.toggleTorch(); - setState(() => _torch = !_torch); - }, - ), - IconButton( - icon: const Icon(Icons.cameraswitch_outlined), - tooltip: t.qrSwitchCamera, - onPressed: () => _controller.switchCamera(), - ), - ], - ), - backgroundColor: Colors.black, - body: Stack( - children: [ - MobileScanner( - controller: _controller, - onDetect: _handleBarcode, - errorBuilder: (context, error, child) => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.no_photography_outlined, - color: Colors.white70, - size: 64, - ), - const SizedBox(height: 16), - Text( - t.qrScannerCameraUnavailable, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Colors.white), - ), - const SizedBox(height: 8), - Text( - error.errorDetails?.message ?? - t.qrScannerCheckPermissions, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ), - // Frame overlay - IgnorePointer( - child: Center( - child: Container( - width: 240, - height: 240, - decoration: BoxDecoration( - border: Border.all( - color: Colors.white.withValues(alpha: 0.85), - width: 2, - ), - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 30, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - t.qrScannerPointHint, - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f13da3e..6482c7f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -744,7 +744,7 @@ class _SettingsScreenState extends State // ne s'exécute jamais et la copie faite par share_plus survit indéfiniment. // Le commentaire de SEC F8 affirmait que ce ménage avait aussi lieu « au // verrouillage » — c'était faux, la fonction n'avait qu'un seul appelant. - _shredShareCache(dir); + _shredStaleExports(dir); final file = File('${dir.path}/pass_tech_export.json'); await file.writeAsString(json); try { @@ -764,7 +764,7 @@ class _SettingsScreenState extends State // indéfiniment à côté — sans mot de passe, sans Keystore, sans Argon2id // pour le protéger. Le plugin accorde en outre une permission de lecture // sur cette copie à toute activité résolvant le sélecteur. - _shredShareCache(dir); + _shredStaleExports(dir); } } @@ -778,19 +778,64 @@ class _SettingsScreenState extends State /// qu'à la relecture croisée. static void _shredFile(File file) => VaultService.shredFileSync(file); - /// Purge le répertoire de cache de `share_plus`, où le plugin recopie tout - /// fichier partagé et n'y fait le ménage qu'au DÉBUT du partage suivant. + /// Vrai si [filePath] est bien une COPIE faite par le sélecteur de fichiers + /// dans notre propre cache, et non le document d'origine de l'utilisateur. /// - /// Appelé AVANT et APRÈS chaque partage. L'appel « avant » n'est pas - /// redondant : c'est lui qui rattrape le cas où le processus a été tué - /// pendant l'affichage du sélecteur, auquel cas le `finally` du partage - /// précédent n'a jamais tourné. - static void _shredShareCache(Directory cacheDir) { + /// Deux conditions, délibérément cumulatives : + /// 1. le chemin est sous le répertoire temporaire de l'application ; + /// 2. il traverse le sous-dossier `file_picker/`, que le plugin fabrique. + /// + /// Vérifié dans `file_picker` 11.0.2 (`FileUtils.kt`), qui écrit sous + /// `/file_picker//`. Exiger les deux plutôt qu'une + /// seule est volontaire : au moindre écart — nouvelle version du plugin, + /// autre plateforme, chemin inattendu — on renonce à effacer. Le pire cas + /// devient « une copie survit jusqu'à `clearTemporaryFiles()` », au lieu de + /// « le fichier de l'utilisateur a disparu ». + static bool _isPickerCacheCopy(String cacheRoot, String filePath) { + final root = cacheRoot.replaceAll('\\', '/'); + final path = filePath.replaceAll('\\', '/'); + final prefix = root.endsWith('/') ? root : '$root/'; + if (!path.startsWith(prefix)) return false; + if (path.contains('/../') || path.endsWith('/..')) return false; + return path.contains('/file_picker/'); + } + + /// Purge tout résidu d'un export précédent dans le répertoire temporaire. + /// + /// Couvre DEUX emplacements, et c'est le second qui manquait : + /// 1. `/share_plus/` — où le plugin recopie chaque fichier partagé, + /// et où il ne fait le ménage qu'au DÉBUT du partage suivant ; + /// 2. **le répertoire temporaire lui-même**, où vivent NOS fichiers + /// (`pass_tech_export.json`, `pass_tech_*.ptbak`). + /// + /// AUDIT 2026-08-03 (Gemini PT-001) — le point 2 est un trou du correctif + /// SEC F8 posé le matin même. Le `finally` de l'export déchiquette bien notre + /// fichier… quand il s'exécute. Si le processus est tué pendant que le + /// sélecteur de partage est à l'écran — l'utilisateur bascule d'application, + /// Android récupère la mémoire — ce `finally` ne tourne JAMAIS, et + /// `pass_tech_export.json`, qui contient l'intégralité des mots de passe **en + /// clair**, reste dans le cache indéfiniment. La purge d'ouverture ne + /// regardait que le sous-dossier du plugin, jamais notre propre fichier. + /// + /// Appelée AVANT et APRÈS chaque partage : c'est l'appel « avant » qui + /// rattrape l'export précédent interrompu. + static void _shredStaleExports(Directory cacheDir) { try { final shareDir = Directory('${cacheDir.path}/share_plus'); - if (!shareDir.existsSync()) return; - for (final ent in shareDir.listSync(followLinks: false)) { - if (ent is File) _shredFile(ent); + if (shareDir.existsSync()) { + for (final ent in shareDir.listSync(followLinks: false)) { + if (ent is File) _shredFile(ent); + } + } + } catch (_) {} + try { + for (final ent in cacheDir.listSync(followLinks: false)) { + if (ent is! File) continue; + final name = ent.uri.pathSegments.last; + if (name == 'pass_tech_export.json' || + (name.startsWith('pass_tech_') && name.endsWith('.ptbak'))) { + _shredFile(ent); + } } } catch (_) {} } @@ -825,7 +870,7 @@ class _SettingsScreenState extends State // une copie complète du coffre, laissée dans un répertoire sur lequel le // plugin accorde une permission de lecture à toute application capable // de répondre au sélecteur. - _shredShareCache(dir); + _shredStaleExports(dir); final file = File('${dir.path}/pass_tech_$date.ptbak'); await file.writeAsString(content); if (!mounted) return; @@ -836,7 +881,7 @@ class _SettingsScreenState extends State ], subject: t.exportEncryptedShareSubject); } finally { _shredFile(file); - _shredShareCache(dir); + _shredStaleExports(dir); } } catch (e) { if (!mounted) return; @@ -881,6 +926,37 @@ class _SettingsScreenState extends State if (!mounted) return; SnackUtils.showError(context, messenger, t.importReadError); return; + } finally { + // AUDIT 2026-08-03 (Gemini PT-002) — purge du cache de FilePicker. + // + // Pour rendre un `path` exploitable à partir d'un `content://`, le + // sélecteur RECOPIE le fichier choisi dans le cache de l'application. + // Cette copie n'était jamais supprimée. Or ce que l'on importe ici, c'est + // typiquement un export **en clair** d'un autre gestionnaire — un JSON + // Bitwarden, un CSV KeePass — c'est-à-dire l'intégralité des mots de + // passe de la personne, dans un fichier non chiffré qui s'installait à + // demeure dans le cache de Pass Tech. + // + // Placé en `finally` : une lecture qui échoue laisse la copie tout autant + // derrière elle. On déchiquette d'abord notre exemplaire, puis on demande + // au plugin de vider le sien. + // + // ⚠️ GARDE OBLIGATOIRE : on ne déchiquette QUE si le chemin est bien dans + // notre répertoire de cache. Vérifié dans `file_picker` 11.0.2, qui copie + // sous `/file_picker//` — mais si une version + // future rendait le chemin RÉEL du fichier choisi, on effacerait le + // document de l'utilisateur lui-même. Un export que l'on vient de lui + // demander d'importer. Le contrôle coûte deux lignes ; l'erreur serait + // irréparable. + try { + final cacheRoot = (await getTemporaryDirectory()).path; + if (_isPickerCacheCopy(cacheRoot, filePath)) { + _shredFile(File(filePath)); + } + } catch (_) {} + try { + await FilePicker.clearTemporaryFiles(); + } catch (_) {} } final file = probeFile; diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 4e30f64..500d799 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -685,7 +685,28 @@ class VaultService { _bioFile = null; } + /// AUDIT 2026-08-03 (Gemini PT-002) — compteur de verrouillages. + /// + /// Incrémenté à CHAQUE [lock]. Toute opération longue qui prend un + /// instantané de l'état du coffre pour le restaurer ensuite doit relever ce + /// compteur avant, et renoncer à restaurer s'il a changé. + /// + /// Sans ce garde-fou, `_passwordMatchesPrimaryInternal` — qui dure le temps + /// d'un Argon2id, soit près d'une seconde — remettait `_key`, `_entries`, + /// `_isOpen` et `_activeSlot` dans son `finally` SANS CONDITION. Un + /// verrouillage survenant pendant ce laps de temps était donc annulé, et le + /// coffre se retrouvait de nouveau déchiffré en mémoire. + /// + /// Les déclencheurs ne sont pas théoriques : passage en arrière-plan avec + /// verrouillage immédiat, minuterie d'inactivité, « Verrouiller maintenant », + /// et surtout `PanicService.panic()`. Le scénario complet : on met à jour + /// l'instantané d'héritage — ce qui appelle `passwordMatchesPrimary` — et on + /// déclenche la panique dans la seconde. La panique verrouillait, puis la + /// vérification rouvrait le coffre derrière elle. + int _lockGeneration = 0; + void lock() { + _lockGeneration++; _wipeKey(); _entries = []; _isOpen = false; diff --git a/lib/services/vault_setup.dart b/lib/services/vault_setup.dart index 76f9211..b2fda6d 100644 --- a/lib/services/vault_setup.dart +++ b/lib/services/vault_setup.dart @@ -127,6 +127,27 @@ extension VaultSetup on VaultService { final hwSecret = SecretBytes.randomBytes(32); Uint8List? pwHash; Uint8List? finalKey; + // AUDIT 2026-08-03 (Gemini PT-001, CRITIQUE) — sauvegarde de la clé en + // cours AVANT toute mutation, pour pouvoir revenir en arrière. + // + // Défaut corrigé : `_key` était remplacée par la NOUVELLE clé juste avant + // `_saveVaultV4`. Si cette écriture échouait — disque plein, erreur d'E/S — + // l'exception remontait jusqu'à l'écran, qui affichait « échec », mais la + // session restait avec : + // • `_key` = la clé NEUVE, + // • le fichier = chiffré sous l'ANCIENNE clé, + // • `_cached*` = l'ancien sel et l'ancienne enveloppe (`_saveVaultV4` ne + // les rafraîchit qu'APRÈS une écriture réussie). + // La moindre modification d'entrée ensuite réécrivait le coffre chiffré + // sous la clé NEUVE en annonçant les métadonnées ANCIENNES. Au + // déverrouillage suivant, la dérivation repartait de l'ancien sel et + // produisait l'ancienne clé : l'étiquette AES-GCM ne pouvait plus se + // vérifier. **Ni l'ancien ni le nouveau mot de passe n'ouvraient plus le + // coffre** — perte définitive, à partir d'un simple disque plein. + final Uint8List? previousKey = _key == null + ? null + : Uint8List.fromList(_key!); + var rotationCommitted = false; try { pwHash = await KdfService.argon2id(password: newPassword, salt: salt); final alias = _aliasFor(slot); @@ -152,6 +173,19 @@ extension VaultSetup on VaultService { // niveau tout seul le jour où son propriétaire change de mot de passe. params: KdfParams.owaspMobile2024, ); + // ⚠️ POINT DE BASCULE — ici, et pas plus bas. + // + // `_saveVaultV4` se termine par un renommage atomique : dès qu'il rend la + // main, le coffre sur disque EST chiffré sous la nouvelle clé. Annuler + // au-delà de cette ligne produirait l'incohérence exactement inverse de + // celle qu'on corrige — une session revenue à l'ancienne clé face à un + // fichier neuf. + // + // L'écriture du sel en stockage qui suit n'est PAS porteuse pour le + // format v4 : le déverrouillage lit `kdf.salt` DANS LE FICHIER + // (cf. `_v4Unlock`). Elle ne sert qu'à périmer l'ancien sel v3, donc son + // échec ne remet pas la rotation en cause. + rotationCommitted = true; // V1 v2.4.0 — salt en storage écrit APRÈS save vault réussi : si le // process est tué entre les deux, on garde l'ancien salt cohérent // avec l'ancien vault au lieu d'un salt orphelin. @@ -160,6 +194,21 @@ extension VaultSetup on VaultService { value: base64Encode(salt), ); } finally { + if (!rotationCommitted) { + // Échec en cours de route : on remet la session dans l'état EXACT + // d'avant l'appel. Le coffre sur disque n'a pas bougé (ou a été + // réécrit à l'identique), donc l'ancienne clé reste la bonne. + // + // Sans cette restauration, `_key` gardait la clé neuve alors que le + // fichier était resté sous l'ancienne : le premier ajout d'entrée + // scellait l'incohérence et rendait le coffre définitivement illisible. + _wipeKey(); + _key = previousKey; + } else if (previousKey != null) { + // Rotation réussie : la copie de secours est du matériel de clé, elle + // ne doit pas traîner en mémoire en attendant le ramasse-miettes. + SecretBytes.wipe(previousKey); + } if (pwHash != null) SecretBytes.wipe(pwHash); SecretBytes.wipe(hwSecret); if (finalKey != null) SecretBytes.wipe(finalKey); diff --git a/lib/services/vault_unlock.dart b/lib/services/vault_unlock.dart index be9c5e4..872c15d 100644 --- a/lib/services/vault_unlock.dart +++ b/lib/services/vault_unlock.dart @@ -123,38 +123,72 @@ extension VaultUnlock on VaultService { final savedEntries = List.from(_entries); final savedOpen = _isOpen; final savedSlot = _activeSlot; + // AUDIT 2026-08-03 (Gemini PT-002) — voir `VaultService._lockGeneration`. + final genAvant = _lockGeneration; + // AUDIT 2026-08-03 — le résultat transite par une variable au lieu de + // sortir par des `return` depuis l'intérieur du `try`. Un `finally` ne + // peut pas modifier une valeur déjà rendue : sans cela, l'appelant + // recevait `true` alors que le coffre venait d'être verrouillé sous lui. + var result = false; try { if (version >= VaultService._currentVersion) { final r = await _v4Unlock(slot: slot, password: password, raw: raw); - if (r == null) return false; - // F3 v2.4.4 — `_v4Unlock` est désormais pur. On wipe tous les - // buffers retournés ; `r.entries` est juste une `List` - // référencée localement (GC). Pas de mutation du state global. - SecretBytes.wipe(r.finalKey); - SecretBytes.wipe(r.salt); - SecretBytes.wipe(r.wrappedDek); - SecretBytes.wipe(r.wrapNonce); - return true; + if (r != null) { + // F3 v2.4.4 — `_v4Unlock` est désormais pur. On wipe tous les + // buffers retournés ; `r.entries` est juste une `List` + // référencée localement (GC). Pas de mutation du state global. + SecretBytes.wipe(r.finalKey); + SecretBytes.wipe(r.salt); + SecretBytes.wipe(r.wrappedDek); + SecretBytes.wipe(r.wrapNonce); + result = true; + } + } else { + // v3 path — derive PBKDF2 then attempt MAC check. + final saltB64 = await VaultService._storage.read( + key: _saltKeyFor(slot), + ); + final iter = + raw['iterations'] as int? ?? VaultService._legacyIterations; + if (saltB64 != null && + iter >= 1 && + iter <= VaultService._maxIterations) { + final salt = base64Decode(saltB64); + _key = await VaultService._deriveKey(password, salt, iter); + result = _decryptVaultV3(raw); + } } - // v3 path — derive PBKDF2 then attempt MAC check. - final saltB64 = await VaultService._storage.read( - key: _saltKeyFor(slot), - ); - if (saltB64 == null) return false; - final salt = base64Decode(saltB64); - final iter = - raw['iterations'] as int? ?? VaultService._legacyIterations; - if (iter < 1 || iter > VaultService._maxIterations) return false; - _key = await VaultService._deriveKey(password, salt, iter); - final ok = _decryptVaultV3(raw); - return ok; } finally { _wipeKey(); - _key = savedKey; - _entries = savedEntries; - _isOpen = savedOpen; - _activeSlot = savedSlot; + if (_lockGeneration != genAvant) { + // Un verrouillage est survenu pendant la vérification — mise en + // arrière-plan, minuterie d'inactivité, « Verrouiller maintenant », + // ou mode panique. Restaurer l'instantané REVIENDRAIT À ANNULER CE + // VERROUILLAGE et à laisser le coffre déchiffré en mémoire. + // + // La décision de l'utilisateur (ou de la panique) prime sur une + // vérification en cours : on efface l'instantané et on laisse le + // coffre fermé. L'appelant recevra `false`, ce qu'il traite déjà + // comme un échec de vérification. + if (savedKey != null) SecretBytes.wipe(savedKey); + savedEntries.clear(); + _key = null; + _entries = []; + _isOpen = false; + _activeSlot = null; + } else { + _key = savedKey; + _entries = savedEntries; + _isOpen = savedOpen; + _activeSlot = savedSlot; + } } + // Un verrouillage survenu pendant la vérification invalide le verdict : + // l'appelant enchaînerait sur une opération sensible (rotation du mot de + // passe maître, écriture de l'instantané d'héritage) alors que le coffre + // vient d'être fermé, volontairement ou par la panique. + if (_lockGeneration != genAvant) return false; + return result; } catch (_) { return false; } diff --git a/pubspec.lock b/pubspec.lock index 0e462c8..7db56ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -199,7 +199,7 @@ packages: path: "../files_tech_core" relative: true source: path - version: "0.3.2" + version: "0.3.4" fixnum: dependency: transitive description: @@ -468,14 +468,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mobile_scanner: - dependency: "direct main" - description: - name: mobile_scanner - sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 - url: "https://pub.dev" - source: hosted - version: "5.2.3" objective_c: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4234aed..6a4de0b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,7 +39,6 @@ dependencies: intl: ^0.20.2 http: ^1.2.0 path_provider: ^2.1.4 - mobile_scanner: ^5.2.3 file_picker: ^11.0.0 flutter_slidable: ^4.0.3 biometric_storage: ^5.0.1 From ad9d0b5e8c636f7d3d98f98f8dd64072701b0c35 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 21:45:27 +0200 Subject: [PATCH 03/34] chore: formatage de tool/ + registrant macOS + audit/ ignore - `tool/generate_icon.dart` reformate. Sans lui, la nouvelle barriere `dart format --output=none --set-exit-if-changed .` ajoutee a ci.yml echouait des le premier run : c'est le seul fichier du depot qui n'etait pas au format. - `GeneratedPluginRegistrant.swift` (macOS) regenere apres le retrait de mobile_scanner. - `audit/` ignore : sorties de l'audit IA externe, pas du code source. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 - tool/generate_icon.dart | 235 ++++++++++++------ 3 files changed, 166 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index ce6a0c2..217492c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ app.*.map.json # Ne doit jamais être versionnée : F-Droid et la CI doivent résoudre la # dépendance git épinglée du pubspec. pubspec_overrides.yaml + +# Sorties d'audit IA externe (audit-gemini.ps1) — non versionnees. +audit/ diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a424fa1..4f71f24 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import biometric_storage import cryptography_flutter import file_picker import flutter_secure_storage_darwin -import mobile_scanner import package_info_plus import share_plus import shared_preferences_foundation @@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/tool/generate_icon.dart b/tool/generate_icon.dart index 186082e..593c0db 100644 --- a/tool/generate_icon.dart +++ b/tool/generate_icon.dart @@ -7,32 +7,32 @@ import 'dart:io'; import 'dart:math' as math; import 'package:image/image.dart' as img; -const _size = 1024; -const _half = _size ~/ 2; +const _size = 1024; +const _half = _size ~/ 2; const _corner = 180; // Quadrant colors (style PDF Tech) -final _blue = img.ColorRgb8(21, 101, 192); -final _red = img.ColorRgb8(198, 40, 40); +final _blue = img.ColorRgb8(21, 101, 192); +final _red = img.ColorRgb8(198, 40, 40); // Gold palette -final _gold = img.ColorRgb8(249, 168, 37); -final _goldHi = img.ColorRgb8(255, 213, 79); +final _gold = img.ColorRgb8(249, 168, 37); +final _goldHi = img.ColorRgb8(255, 213, 79); final _goldDark = img.ColorRgb8(176, 109, 0); // Lock palette (white lock with dark keyhole) -final _white = img.ColorRgb8(255, 255, 255); +final _white = img.ColorRgb8(255, 255, 255); final _whiteShad = img.ColorRgb8(220, 220, 220); -final _khDark = img.ColorRgb8(40, 30, 0); +final _khDark = img.ColorRgb8(40, 30, 0); -final _outline = img.ColorRgb8(35, 35, 35); +final _outline = img.ColorRgb8(35, 35, 35); // Shield bounding box -const _shTop = 180; -const _shBottom = 900; -const _shHalfW = 290; +const _shTop = 180; +const _shBottom = 900; +const _shHalfW = 290; const _shStraight = 540; -const _shTopRad = 56; +const _shTopRad = 56; // Lock geometry (centré visuellement dans le bouclier — partie haute, le // bouclier ayant son "poids visuel" dans la moitié supérieure à cause du @@ -53,18 +53,23 @@ img.Color _quadrantAt(int x, int y) { } void _fillQuadrants(img.Image image) { - img.fillRect(image, x1: 0, y1: 0, x2: _half, y2: _half, color: _blue); - img.fillRect(image, x1: _half, y1: 0, x2: _size, y2: _half, color: _red); - img.fillRect(image, x1: 0, y1: _half, x2: _half, y2: _size, color: _red); + img.fillRect(image, x1: 0, y1: 0, x2: _half, y2: _half, color: _blue); + img.fillRect(image, x1: _half, y1: 0, x2: _size, y2: _half, color: _red); + img.fillRect(image, x1: 0, y1: _half, x2: _half, y2: _size, color: _red); img.fillRect(image, x1: _half, y1: _half, x2: _size, y2: _size, color: _blue); } void _roundCorners(img.Image image) { final corners = [ - (cx: _corner, cy: _corner, x0: 0, y0: 0), - (cx: _size - _corner, cy: _corner, x0: _size - _corner, y0: 0), - (cx: _corner, cy: _size - _corner, x0: 0, y0: _size - _corner), - (cx: _size - _corner, cy: _size - _corner, x0: _size - _corner, y0: _size - _corner), + (cx: _corner, cy: _corner, x0: 0, y0: 0), + (cx: _size - _corner, cy: _corner, x0: _size - _corner, y0: 0), + (cx: _corner, cy: _size - _corner, x0: 0, y0: _size - _corner), + ( + cx: _size - _corner, + cy: _size - _corner, + x0: _size - _corner, + y0: _size - _corner, + ), ]; for (final c in corners) { for (int dy = 0; dy < _corner; dy++) { @@ -99,12 +104,28 @@ int _shieldHalfWidthAt(int y, [int extra = 0]) { return w < 0 ? 0 : w; } -void _fillShield(img.Image image, img.Color color, {int extra = 0, int yStart = 0, int yEnd = _size}) { - for (int y = math.max(yStart, _shTop - extra); y < math.min(yEnd, _shBottom + extra + 1); y++) { +void _fillShield( + img.Image image, + img.Color color, { + int extra = 0, + int yStart = 0, + int yEnd = _size, +}) { + for ( + int y = math.max(yStart, _shTop - extra); + y < math.min(yEnd, _shBottom + extra + 1); + y++ + ) { final hw = _shieldHalfWidthAt(y, extra); if (hw <= 0) continue; - img.fillRect(image, - x1: _half - hw, y1: y, x2: _half + hw, y2: y + 1, color: color); + img.fillRect( + image, + x1: _half - hw, + y1: y, + x2: _half + hw, + y2: y + 1, + color: color, + ); } } @@ -119,10 +140,14 @@ void _drawShield(img.Image image) { final hw = _shieldHalfWidthAt(y); if (hw <= 0) continue; final hiW = (hw * 0.10).round(); - img.fillRect(image, - x1: _half - hw + 12, y1: y, - x2: _half - hw + 12 + hiW, y2: y + 1, - color: img.ColorRgb8(245, 245, 250)); + img.fillRect( + image, + x1: _half - hw + 12, + y1: y, + x2: _half - hw + 12 + hiW, + y2: y + 1, + color: img.ColorRgb8(245, 245, 250), + ); } // Bande d'ombre à droite (gris léger pour donner du volume) @@ -130,14 +155,26 @@ void _drawShield(img.Image image) { final hw = _shieldHalfWidthAt(y); if (hw <= 0) continue; final shW = (hw * 0.16).round(); - img.fillRect(image, - x1: _half + hw - shW - 14, y1: y, - x2: _half + hw - 14, y2: y + 1, - color: _whiteShad); + img.fillRect( + image, + x1: _half + hw - shW - 14, + y1: y, + x2: _half + hw - 14, + y2: y + 1, + color: _whiteShad, + ); } } -void _roundRect(img.Image image, int x1, int y1, int x2, int y2, int r, img.Color c) { +void _roundRect( + img.Image image, + int x1, + int y1, + int x2, + int y2, + int r, + img.Color c, +) { img.fillRect(image, x1: x1 + r, y1: y1, x2: x2 - r, y2: y2, color: c); img.fillRect(image, x1: x1, y1: y1 + r, x2: x2, y2: y2 - r, color: c); img.fillCircle(image, x: x1 + r, y: y1 + r, radius: r, color: c); @@ -169,26 +206,49 @@ void _restoreShieldRect(img.Image image, int x1, int y1, int x2, int y2) { void _drawLock(img.Image image) { // Contour cadenas - img.fillCircle(image, x: _shackleCx, y: _shackleCy, - radius: _shackleOuter + 6, color: _outline); - _roundRect(image, - _lockBodyX1 - 6, _lockBodyY1 - 6, - _lockBodyX2 + 6, _lockBodyY2 + 6, - _lockBodyRadius + 4, _outline); + img.fillCircle( + image, + x: _shackleCx, + y: _shackleCy, + radius: _shackleOuter + 6, + color: _outline, + ); + _roundRect( + image, + _lockBodyX1 - 6, + _lockBodyY1 - 6, + _lockBodyX2 + 6, + _lockBodyY2 + 6, + _lockBodyRadius + 4, + _outline, + ); // Shackle doré (anneau) - img.fillCircle(image, x: _shackleCx, y: _shackleCy, - radius: _shackleOuter, color: _gold); + img.fillCircle( + image, + x: _shackleCx, + y: _shackleCy, + radius: _shackleOuter, + color: _gold, + ); _restoreShieldCircle(image, _shackleCx, _shackleCy, _shackleInner); // Cover bottom of shackle → U-shape - _restoreShieldRect(image, - _shackleCx - _shackleOuter - 4, _shackleCy + 28, - _shackleCx + _shackleOuter + 4, _lockBodyY1 - 6); + _restoreShieldRect( + image, + _shackleCx - _shackleOuter - 4, + _shackleCy + 28, + _shackleCx + _shackleOuter + 4, + _lockBodyY1 - 6, + ); // Highlight clair sur le shackle (côté gauche) for (int y = _shackleCy - _shackleOuter + 10; y < _shackleCy; y++) { - for (int x = _shackleCx - _shackleOuter + 6; x < _shackleCx - _shackleInner - 8; x++) { + for ( + int x = _shackleCx - _shackleOuter + 6; + x < _shackleCx - _shackleInner - 8; + x++ + ) { final dx = x - _shackleCx; final dy = y - _shackleCy; final d2 = dx * dx + dy * dy; @@ -200,33 +260,62 @@ void _drawLock(img.Image image) { } // Body cadenas doré - _roundRect(image, _lockBodyX1, _lockBodyY1, _lockBodyX2, _lockBodyY2, - _lockBodyRadius, _gold); + _roundRect( + image, + _lockBodyX1, + _lockBodyY1, + _lockBodyX2, + _lockBodyY2, + _lockBodyRadius, + _gold, + ); // Highlight gauche du body (bande verticale claire) - img.fillRect(image, - x1: _lockBodyX1 + 14, y1: _lockBodyY1 + 18, - x2: _lockBodyX1 + 38, y2: _lockBodyY2 - 18, - color: _goldHi); + img.fillRect( + image, + x1: _lockBodyX1 + 14, + y1: _lockBodyY1 + 18, + x2: _lockBodyX1 + 38, + y2: _lockBodyY2 - 18, + color: _goldHi, + ); // Ombre droite du body (bronze) - img.fillRect(image, - x1: _lockBodyX2 - 32, y1: _lockBodyY1 + 18, - x2: _lockBodyX2 - 12, y2: _lockBodyY2 - 18, - color: _goldDark); + img.fillRect( + image, + x1: _lockBodyX2 - 32, + y1: _lockBodyY1 + 18, + x2: _lockBodyX2 - 12, + y2: _lockBodyY2 - 18, + color: _goldDark, + ); // Keyhole (cercle + tige) en sombre — contraste sur l'or - img.fillCircle(image, x: _khCx, y: _khCy, radius: _khRadius + 3, color: _outline); - img.fillRect(image, - x1: _khStemX1 - 3, y1: _khCy, - x2: _khStemX2 + 3, y2: _khStemY2 + 3, - color: _outline); + img.fillCircle( + image, + x: _khCx, + y: _khCy, + radius: _khRadius + 3, + color: _outline, + ); + img.fillRect( + image, + x1: _khStemX1 - 3, + y1: _khCy, + x2: _khStemX2 + 3, + y2: _khStemY2 + 3, + color: _outline, + ); img.fillCircle(image, x: _khCx, y: _khCy, radius: _khRadius, color: _khDark); - img.fillRect(image, - x1: _khStemX1, y1: _khCy, - x2: _khStemX2, y2: _khStemY2, - color: _khDark); + img.fillRect( + image, + x1: _khStemX1, + y1: _khCy, + x2: _khStemX2, + y2: _khStemY2, + color: _khDark, + ); } void main() { @@ -242,17 +331,19 @@ void main() { print('Wrote assets/icon.png ($_size x $_size)'); const sizes = { - 'mdpi': 48, - 'hdpi': 72, - 'xhdpi': 96, - 'xxhdpi': 144, + 'mdpi': 48, + 'hdpi': 72, + 'xhdpi': 96, + 'xxhdpi': 144, 'xxxhdpi': 192, }; for (final entry in sizes.entries) { - final resized = img.copyResize(image, - width: entry.value, - height: entry.value, - interpolation: img.Interpolation.cubic); + final resized = img.copyResize( + image, + width: entry.value, + height: entry.value, + interpolation: img.Interpolation.cubic, + ); final path = 'android/app/src/main/res/mipmap-${entry.key}/ic_launcher.png'; File(path).writeAsBytesSync(img.encodePng(resized)); print('Wrote $path (${entry.value}x${entry.value})'); From 7f70f3a32f1c90d5d816945571c053e221b48228 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 21:53:19 +0200 Subject: [PATCH 04/34] =?UTF-8?q?feat(ui):=20Reglages=20presentes=20comme?= =?UTF-8?q?=20=C2=AB=20A=20propos=20=C2=BB=20+=20titres=20agrandis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avant : neuf sections de ListTile nus, bord a bord, titres 12 sp en bleu primaire. Le choix du theme et « Tout supprimer » avaient le meme poids visuel. Apres, en reprenant le vocabulaire deja etabli par about_screen : - marges 16/24/16/40 identiques ; - chaque reglage sur sa propre carte (rayon + bordure du theme, donc correct en clair comme en sombre, sans couleur codee en dur) ; - titres de section en titleMedium (16 sp au lieu de 12), gras, en onSurfaceVariant. Plus gros qu'« A propos » a dessein : neuf sections se parcourent du regard, alors qu'« A propos » se lit d'un trait. METHODE — la decoration est appliquee a la LISTE (`_decorate`), pas ecrite sur chaque tuile. Les ~400 lignes de onTap, dialogues et FutureBuilder de cet ecran ne sont pas modifiees : envelopper chaque tuile a la main aurait ete l'occasion d'egarer un branchement sans que l'analyse ni les tests ne le signalent. Un cas ne peut pas etre decore automatiquement : « Reveler l'application » ne s'affiche que si le camouflage est actif. L'envelopper poserait une carte VIDE sur l'ecran de tout le monde. Marque `_Undecorated`, il gere sa carte a l'interieur de sa condition. flutter analyze : 0 issue. Rendu NON verifie par moi (FLAG_SECURE bloque screencap ; uiautomator exigerait le mot de passe maitre) — a valider a l'oeil. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/settings_screen.dart | 116 ++++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6482c7f..7fd83f9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1197,8 +1197,26 @@ class _SettingsScreenState extends State return Scaffold( appBar: AppBar(title: Text(t.settingsTitle)), + // UI 2026-08-03 — Réglages aligné sur la présentation de « À propos ». + // + // Avant : une `ListView` de `ListTile` nus, bord à bord, où neuf sections + // se distinguaient uniquement par un petit titre coloré. Les réglages de + // sécurité, les actions destructrices et le choix du thème avaient + // exactement le même poids visuel. + // + // Désormais : mêmes marges (16/24/16/40), mêmes titres discrets et + // surtout chaque réglage posé sur sa propre carte — le vocabulaire déjà + // employé par « À propos ». Les cartes portent le rayon et la bordure + // définis par le thème, donc l'écran suit automatiquement le mode clair + // comme le mode sombre. + // + // La décoration est appliquée à la LISTE, pas à chaque élément : les + // tuiles restent inchangées, avec leurs `onTap` et leurs `FutureBuilder`. + // C'est le seul moyen de refondre la présentation sans risquer d'égarer + // un branchement au passage. body: ListView( - children: [ + padding: const EdgeInsets.fromLTRB(16, 24, 16, 40), + children: _decorate([ _section(t.settingsSectionAppearance), ListTile( leading: const Icon(Icons.brightness_6_outlined), @@ -1487,21 +1505,29 @@ class _SettingsScreenState extends State trailing: const Icon(Icons.chevron_right, size: 18), onTap: _triggerPanic, ), - FutureBuilder( - future: PanicService.isDisguised(), - builder: (_, snap) { - if (snap.data != true) return const SizedBox.shrink(); - return ListTile( - leading: Icon(Icons.visibility, color: cs.primary), - title: Text(t.panicRevealTitle), - subtitle: Text( - t.panicRevealSubtitle, - style: const TextStyle(fontSize: 12), - ), - trailing: const Icon(Icons.chevron_right, size: 18), - onTap: _revealApp, - ); - }, + // `_Undecorated` : ce bloc ne s'affiche QUE si le camouflage est + // actif. Sans cette marque, la décoration poserait une carte vide sur + // l'écran de tout le monde. + _Undecorated( + child: FutureBuilder( + future: PanicService.isDisguised(), + builder: (_, snap) { + if (snap.data != true) return const SizedBox.shrink(); + return Card( + margin: const EdgeInsets.only(bottom: 6), + child: ListTile( + leading: Icon(Icons.visibility, color: cs.primary), + title: Text(t.panicRevealTitle), + subtitle: Text( + t.panicRevealSubtitle, + style: const TextStyle(fontSize: 12), + ), + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: _revealApp, + ), + ); + }, + ), ), _section(t.heritageSection), @@ -1585,26 +1611,68 @@ class _SettingsScreenState extends State subtitle: Text(t.settingsDeleteAllSubtitle), onTap: _deleteAll, ), - const SizedBox(height: 20), - ], + ]), ), ); } - Widget _section(String title) => Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + /// Pose chaque réglage sur sa propre carte, en laissant passer les titres de + /// section et les éléments qui gèrent déjà leur propre encadrement. + /// + /// Volontairement appliqué à la liste entière plutôt qu'écrit sur chaque + /// tuile : la centaine de lignes de `onTap`, de dialogues et de + /// `FutureBuilder` de cet écran n'est pas touchée, donc aucune régression de + /// comportement n'est possible — seule la présentation change. + List _decorate(List items) => [ + for (final w in items) + if (w is _SectionTitle || w is _Undecorated) + w + else + Card(margin: const EdgeInsets.only(bottom: 6), child: w), + ]; + + Widget _section(String title) => _SectionTitle(title); +} + +/// Titre de section, repris tel quel de « À propos » : discret, en +/// `onSurfaceVariant`, il structure sans capter le regard. L'espacement fait +/// partie du widget pour que la liste reste lisible à la lecture du code. +class _SectionTitle extends StatelessWidget { + final String title; + const _SectionTitle(this.title); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(2, 20, 2, 8), child: Text( title, - style: TextStyle( - fontSize: 12, + // UI 2026-08-03 — titres agrandis à la demande. `titleMedium` (16 sp) + // plutôt que le `titleSmall` (14 sp) de « À propos » : les Réglages + // comptent neuf sections, on y navigue en cherchant un titre du regard, + // alors qu'« À propos » se lit d'un trait. + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.primary, - letterSpacing: 0.5, + letterSpacing: 0.3, ), ), ); } +/// Marque un élément qui décide lui-même de son encadrement. +/// +/// Nécessaire pour les blocs dont le contenu peut être VIDE : les envelopper +/// systématiquement dans une carte laisserait une carte vide à l'écran. C'est +/// le cas du bouton « Révéler l'application », affiché seulement quand le mode +/// panique est actif. +class _Undecorated extends StatelessWidget { + final Widget child; + const _Undecorated({required this.child}); + + @override + Widget build(BuildContext context) => child; +} + class _PassphraseDialog extends StatefulWidget { final String title; final bool confirm; From 0d6fd3f78011e14035a56e46374a00b61cd69752 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 21:56:28 +0200 Subject: [PATCH 05/34] feat(ui): titres des Reglages au bleu de la marque (cs.primary) Le bleu du damier du logo est #0B5FC7. Il n'est PAS code en dur : sur le fond sombre #0D1117 il ne donne qu'environ 3,4:1 de contraste, sous l'exigence AA de 4,5:1. `cs.primary` rend ce meme bleu en theme clair et bascule sur #58A6FF en sombre. Meme raisonnement que le correctif U5 v2.4.4 sur les `Colors.grey` codes en dur. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/settings_screen.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7fd83f9..ace5a1c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1650,8 +1650,18 @@ class _SectionTitle extends StatelessWidget { // plutôt que le `titleSmall` (14 sp) de « À propos » : les Réglages // comptent neuf sections, on y navigue en cherchant un titre du regard, // alors qu'« À propos » se lit d'un trait. + // + // Couleur : `cs.primary`, c'est-à-dire le bleu de la marque — celui du + // damier du logo (#0B5FC7) en thème clair. + // + // ⚠️ Ce bleu n'est PAS codé en dur, et il ne faut pas le faire : sur le + // fond sombre (#0D1117) il ne donne qu'environ 3,4:1 de contraste, sous + // l'exigence AA de 4,5:1. `cs.primary` rend le bleu du damier en clair et + // bascule sur #58A6FF en sombre, où le contraste repasse au-dessus du + // seuil. C'est la même erreur que celle corrigée en v2.4.4 sur les + // `Colors.grey` codés en dur (U5). style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w700, letterSpacing: 0.3, ), From 2c9cb7f9412ddc92e67eae3ac24ca615755e6497 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 22:09:19 +0200 Subject: [PATCH 06/34] fix(securite): sortie du camouflage accessible sans ouvrir le coffre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaut trouve EN USAGE REEL, pas en audit — Patrice a active le mode panique pour le tester, puis n'a plus retrouve son mot de passe maitre. « Reveler l'application » n'existait que dans les Reglages, donc DERRIERE le deverrouillage. Consequence : qui active la panique et oublie son mot de passe se retrouve avec une application definitivement deguisee en calculatrice sur son lanceur, sans aucun moyen de revenir en arriere — alors que le camouflage est reversible par conception. CalculatorActivity documentait deja ce piege pour un CODE NUMERIQUE oublie (« le bouton Reveler vit dans les Reglages, devenus inatteignables »). Personne n'avait vu qu'il vaut a l'identique pour un MOT DE PASSE oublie, cas autrement plus frequent. Le bouton apparait desormais sur l'ecran de deverrouillage, uniquement quand le camouflage est actif. Aucune fuite pour le deni plausible : pour lire cet ecran il faut deja etre sorti de la calculatrice, donc le camouflage est tombe. Et l'action ne touche QUE l'icone du lanceur — elle n'ouvre rien, ne dechiffre rien, ne revele aucune donnee. Chaines l10n reutilisees (panicRevealTitle / panicRevealSnack), aucun ajout ARB. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/unlock_screen.dart | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/lib/screens/unlock_screen.dart b/lib/screens/unlock_screen.dart index 0e5b9f5..4363c7f 100644 --- a/lib/screens/unlock_screen.dart +++ b/lib/screens/unlock_screen.dart @@ -7,6 +7,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_localizations.dart'; import '../services/heritage_service.dart'; import '../services/integrity_service.dart'; +import '../services/panic_service.dart'; +import '../utils/snack_utils.dart'; import '../services/vault_service.dart'; import '../widgets/password_text_field.dart'; import 'heir_view_screen.dart'; @@ -40,10 +42,32 @@ class _UnlockScreenState extends State { /// l'horloge du dispositif d'héritage. late final Future _heirOptionFuture; + /// SEC 2026-08-03 — le camouflage doit pouvoir être défait SANS ouvrir le + /// coffre. + /// + /// Défaut constaté en usage réel, pas en audit : « Révéler l'application » + /// n'existait que dans les Réglages, donc derrière le déverrouillage. Un + /// propriétaire qui active la panique puis ne retrouve plus son mot de passe + /// maître se retrouve avec une application **définitivement déguisée en + /// calculatrice** sur son lanceur, sans aucun moyen de revenir en arrière — + /// alors que le camouflage est réversible par conception. + /// + /// `CalculatorActivity` documentait déjà ce piège pour un code numérique + /// oublié (« le bouton Révéler vit dans les Réglages, devenus + /// inatteignables ») ; il n'avait pas été vu qu'il vaut à l'identique pour un + /// mot de passe oublié, cas autrement plus fréquent. + /// + /// Aucune fuite pour le déni plausible : pour lire cet écran il faut déjà + /// être sorti de la calculatrice, donc le camouflage est de toute façon + /// tombé. Et l'action ne touche QUE l'icône du lanceur — elle n'ouvre rien, + /// ne déchiffre rien, ne révèle aucune donnée. + late final Future _disguisedFuture; + @override void initState() { super.initState(); _heirOptionFuture = HeritageService().shouldShowHeirOption(); + _disguisedFuture = PanicService.isDisguised(); _checkLockout(); _checkBiometric(); _checkIntegrity(); @@ -430,6 +454,25 @@ class _UnlockScreenState extends State { ); } + /// Rétablit l'icône et le nom Pass Tech sur le lanceur, sans déverrouiller. + /// + /// Volontairement SANS confirmation : quand on arrive ici, on a déjà traversé + /// la calculatrice, donc le camouflage ne protège plus rien. Un dialogue de + /// plus ne ferait qu'ajouter un obstacle à quelqu'un qui cherche justement à + /// sortir d'une situation bloquée. + Future _revealFromLockScreen() async { + final messenger = ScaffoldMessenger.of(context); + final t = AppLocalizations.of(context); + await PanicService.revealApp(); + if (!mounted) return; + setState(() {}); // masque le bouton, le camouflage n'est plus actif + SnackUtils.showInfo( + messenger, + t.panicRevealSnack, + duration: const Duration(seconds: 5), + ); + } + String _formatLockout(int seconds) { if (seconds < 60) return '${seconds}s'; final m = seconds ~/ 60; @@ -574,6 +617,26 @@ class _UnlockScreenState extends State { // propriétaire dépasse le seuil + grâce expirée. Le // FutureBuilder ne renvoie l'option qu'après le check // crypto, pas de leak temporel. + // SEC 2026-08-03 — sortie de secours du camouflage, + // accessible SANS ouvrir le coffre. Voir + // `_disguisedFuture`. N'apparaît que si le camouflage + // est effectivement actif. + FutureBuilder( + future: _disguisedFuture, + builder: (_, snap) { + if (snap.data != true) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 12), + child: TextButton.icon( + onPressed: _revealFromLockScreen, + icon: const Icon(Icons.visibility, size: 18), + label: Text(t.panicRevealTitle), + ), + ); + }, + ), FutureBuilder( future: _heirOptionFuture, builder: (_, snap) { From 3464c8d5c5436455054a9528a295fe1a44b82e33 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 22:21:20 +0200 Subject: [PATCH 07/34] fix(securite): 13 findings du 2e audit Gemini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2e passe Gemini 3.1-pro sur le code ecrit dans la journee — celui que seul moi avais relu. 15 findings, 13 confirmees apres relecture du code. GRAVE - Anti-hameconnage contourne sur Firefox : le mapping lisait `url_bar_title`, qui contient le TITRE DE PAGE et non l'URL. Un site pirate declarant `mabanque.fr` faisait rendre le verdict « ok » par la protection elle-meme. Le garde-fou TLD ne visait que les titres ACCIDENTELS, pas un titre choisi expres. Champ retire. - Compteur d'echecs incremente APRES la derivation : tuer l'app pendant les ~2 s d'Argon2id annulait l'essai, supprimant de fait le verrouillage progressif. Vaut pour le mot de passe MAITRE (non signale par Gemini, trouve en verifiant) ET pour l'heritier. L'essai est desormais provisionne AVANT, et efface par un deverrouillage reussi. Plancher a 1 si la reservation echoue — sinon une erreur de stockage desarmait le verrouillage. - Export en clair et suppression du coffre sans re-authentification. SEC F10 v2.5.2 avait ajoute ce controle au changement de mot de passe en nommant la menace (« acces momentane a une session deverrouillee ») ; il n'avait ete propage ni a l'un ni a l'autre — alors que l'export est PIRE, il emporte tout hors de l'appareil. MOYEN - 5 tampons `Uint8List.fromList(utf8.encode(...))` : `utf8.encode` rend deja un Uint8List, l'enveloppe creait une COPIE FANTOME du coffre en clair ou du mot de passe, jamais effacee. `vault_storage.dart` documente ce piege et l'evite depuis la v2.5.x — la regle n'avait pas ete propagee aux 5 autres sites. - 3 champs Notes sans `enableSuggestions:false` / `autocorrect:false` : Gboard apprenait et synchronisait phrases de recuperation et codes de secours. `PasswordTextField` posait deja ces drapeaux ; les TextField bruts non. - Vue heritier : mots de passe affiches en clair. Masques derriere un oeil, comme partout ailleurs dans l'app. FAIBLE - Le tampon du mot de passe MAITRE n'etait pas vide au `dispose` de l'ecran de deverrouillage — seul champ sensible de l'app a ne pas le faire (cf. B8/B9). - `gradlew assemble` / `build` contournaient la garde keystore de SEC F15 : SEC-R2 avait resserre le filtre sur « assembleRelease », or les taches generiques construisent la release sans contenir ce mot. Release signee avec la cle de DEBUG. ECARTEES : FLAG_SECURE relache sur les notes (arbitrage assume, deja traite) ; reararmement asynchrone de FLAG_SECURE (reel mais deja arbitre, correctif natif a evaluer separement). MOTIF DOMINANT, 3e fois de la journee : la parade existait deja dans le depot et n'avait pas ete propagee a son jumeau. flutter analyze : 0 issue. 139 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- android/app/build.gradle.kts | 26 ++++- .../pass_tech/PhishingDetectorService.kt | 21 +++- lib/l10n/app_en.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/screens/entry_edit_screen.dart | 45 ++++++++ lib/screens/heir_view_screen.dart | 85 +++++++++++---- lib/screens/settings_screen.dart | 102 ++++++++++++++++++ lib/screens/unlock_screen.dart | 36 +++++-- lib/services/heritage_service.dart | 27 ++++- lib/services/import_export_service.dart | 24 +++-- lib/services/kdf_service.dart | 10 +- lib/services/vault_brute_force.dart | 48 ++++++++- lib/services/vault_service.dart | 8 +- 13 files changed, 384 insertions(+), 52 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9a46a9a..2459472 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -95,12 +95,28 @@ android { // qui n'impliquent aucune signature : un futur job CI exécutant // l'une d'elles sans keystore aurait échoué sur un message // trompeur parlant de refus de signature. + // SEC 2026-08-03 (Gemini PT-003) — le filtre couvre aussi les + // tâches GÉNÉRIQUES. + // + // SEC-R2 v2.5.2 avait resserré la détection sur `assembleRelease` / + // `bundleRelease` pour ne plus faire échouer `testReleaseUnitTest` + // ou `lintRelease`. Le resserrage est allé trop loin : `gradlew + // assemble` et `gradlew build` construisent TOUTES les variantes, + // release comprise, sans jamais contenir le mot « Release ». La + // garde ne se déclenchait donc pas et l'APK release repartait signé + // avec la clé de DEBUG, publiquement connue — exactement ce que + // SEC F15 existe pour empêcher. + // + // On teste désormais l'égalité stricte sur les tâches génériques + // (et non `contains`, qui rattraperait `assembleDebug`), en plus + // des deux tâches explicites. val keyPropsFile = rootProject.file("key.properties") - if (!keyPropsFile.exists() && - gradle.startParameter.taskNames.any { - it.contains("assembleRelease") || it.contains("bundleRelease") - } - ) { + val releaseTaskDemande = gradle.startParameter.taskNames.any { nom -> + val court = nom.substringAfterLast(':') + court == "assemble" || court == "build" || + nom.contains("assembleRelease") || nom.contains("bundleRelease") + } + if (!keyPropsFile.exists() && releaseTaskDemande) { throw GradleException( "key.properties introuvable : refus de signer un build " + "release avec la clé de DEBUG. Restaure " + diff --git a/android/app/src/main/kotlin/com/passtech/pass_tech/PhishingDetectorService.kt b/android/app/src/main/kotlin/com/passtech/pass_tech/PhishingDetectorService.kt index 224ca37..9e2d9b5 100644 --- a/android/app/src/main/kotlin/com/passtech/pass_tech/PhishingDetectorService.kt +++ b/android/app/src/main/kotlin/com/passtech/pass_tech/PhishingDetectorService.kt @@ -117,9 +117,26 @@ class PhishingDetectorService : AccessibilityService() { "com.opera.browser" to listOf( "com.opera.browser:id/url_field" ), + // SEC 2026-08-03 — `url_bar_title` RETIRÉ. + // + // Ce champ contient le TITRE DE LA PAGE, pas l'URL. Or le titre est + // choisi par le site : une page d'hameçonnage qui déclare + // `mabanque.fr` faisait lire « mabanque.fr » au + // service, qui le validait — il a un TLD plausible — et + // l'anti-hameçonnage rendait le verdict « ok » sur le site pirate + // lui-même. La protection se retournait contre l'utilisateur. + // + // Le commentaire de `TLD_REGEX` avait bien vu que des titres + // pouvaient apparaître ici, mais la parade retenue — « exiger un + // TLD » — ne vise que les titres ACCIDENTELS (« LeMonde.fr »). Elle + // ne peut rien contre un titre choisi exprès. + // + // Seule la barre d'adresse fait foi. Conséquence assumée : si + // Firefox n'expose pas ce champ dans une version donnée, le verdict + // devient « inconnu » — l'app le dit à l'utilisateur au lieu de le + // rassurer à tort. "org.mozilla.firefox" to listOf( - "org.mozilla.firefox:id/mozac_browser_toolbar_url_view", - "org.mozilla.firefox:id/url_bar_title" + "org.mozilla.firefox:id/mozac_browser_toolbar_url_view" ), "org.mozilla.fenix" to listOf( "org.mozilla.fenix:id/mozac_browser_toolbar_url_view" diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b3df313..3f66e61 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -510,6 +510,8 @@ "settingsDeleteAllSubtitle": "Irreversible action", "settingsDeleteAllDialogTitle": "Delete all data?", "settingsDeleteAllDialogBody": "All your entries and the vault will be permanently deleted.", + "reauthTitle": "Confirm your identity", + "reauthBody": "Enter your master password to continue.", "settingsDeleteAllConfirm": "Delete everything", "exportPlainDialogTitle": "UNENCRYPTED export", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a641244..6163f71 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -494,6 +494,8 @@ "settingsDeleteAllSubtitle": "Action irréversible", "settingsDeleteAllDialogTitle": "Supprimer toutes les données ?", "settingsDeleteAllDialogBody": "Toutes vos entrées et le coffre-fort seront supprimés définitivement.", + "reauthTitle": "Confirmez votre identité", + "reauthBody": "Saisissez votre mot de passe maître pour continuer.", "settingsDeleteAllConfirm": "Tout supprimer", "exportPlainDialogTitle": "Export NON CHIFFRÉ", diff --git a/lib/screens/entry_edit_screen.dart b/lib/screens/entry_edit_screen.dart index 08102e4..b262b1a 100644 --- a/lib/screens/entry_edit_screen.dart +++ b/lib/screens/entry_edit_screen.dart @@ -498,6 +498,21 @@ class _EntryEditScreenState extends State { const SizedBox(height: 6), TextField( controller: _notesCtrl, + // SEC 2026-08-03 (Gemini PASS_TECH-02) — pas d'apprentissage clavier. + // + // Sans ces deux drapeaux, les claviers tiers (Gboard, SwiftKey) et le + // dictionnaire personnel d'Android apprennent ce qui est saisi ici, + // le proposent ensuite en suggestion DANS D'AUTRES APPLICATIONS, et le + // synchronisent parfois vers le nuage de leur editeur. Or ce champ + // recoit precisement ce que l'utilisateur ne veut confier a personne : + // phrases de recuperation, codes de secours, IBAN. + // + // `PasswordTextField` posait deja ces drapeaux pour tous les champs de + // mot de passe ; les champs de notes, en `TextField` brut, ne les + // avaient jamais recus. Meme motif que d'habitude : la parade existe + // dans le depot, elle n'avait pas ete propagee a son jumeau. + enableSuggestions: false, + autocorrect: false, // UI v2.5.3 — cf. le champ Contenu des notes : croissance au lieu d'un // défilement imbriqué, et sélection collée aux glyphes. maxLines: null, @@ -521,6 +536,21 @@ class _EntryEditScreenState extends State { const SizedBox(height: 6), TextField( controller: _notesCtrl, + // SEC 2026-08-03 (Gemini PASS_TECH-02) — pas d'apprentissage clavier. + // + // Sans ces deux drapeaux, les claviers tiers (Gboard, SwiftKey) et le + // dictionnaire personnel d'Android apprennent ce qui est saisi ici, + // le proposent ensuite en suggestion DANS D'AUTRES APPLICATIONS, et le + // synchronisent parfois vers le nuage de leur editeur. Or ce champ + // recoit precisement ce que l'utilisateur ne veut confier a personne : + // phrases de recuperation, codes de secours, IBAN. + // + // `PasswordTextField` posait deja ces drapeaux pour tous les champs de + // mot de passe ; les champs de notes, en `TextField` brut, ne les + // avaient jamais recus. Meme motif que d'habitude : la parade existe + // dans le depot, elle n'avait pas ete propagee a son jumeau. + enableSuggestions: false, + autocorrect: false, // UI v2.5.3 — `maxLines: null` fait GRANDIR le champ au lieu de lui // donner son propre défilement interne. Avec `maxLines: 12`, le champ // devenait un scrollable imbriqué dans le `SingleChildScrollView` de @@ -669,6 +699,21 @@ class _EntryEditScreenState extends State { const SizedBox(height: 6), TextField( controller: _notesCtrl, + // SEC 2026-08-03 (Gemini PASS_TECH-02) — pas d'apprentissage clavier. + // + // Sans ces deux drapeaux, les claviers tiers (Gboard, SwiftKey) et le + // dictionnaire personnel d'Android apprennent ce qui est saisi ici, + // le proposent ensuite en suggestion DANS D'AUTRES APPLICATIONS, et le + // synchronisent parfois vers le nuage de leur editeur. Or ce champ + // recoit precisement ce que l'utilisateur ne veut confier a personne : + // phrases de recuperation, codes de secours, IBAN. + // + // `PasswordTextField` posait deja ces drapeaux pour tous les champs de + // mot de passe ; les champs de notes, en `TextField` brut, ne les + // avaient jamais recus. Meme motif que d'habitude : la parade existe + // dans le depot, elle n'avait pas ete propagee a son jumeau. + enableSuggestions: false, + autocorrect: false, // UI v2.5.3 — cf. le champ Contenu des notes. maxLines: null, minLines: 2, diff --git a/lib/screens/heir_view_screen.dart b/lib/screens/heir_view_screen.dart index b633775..b21f92c 100644 --- a/lib/screens/heir_view_screen.dart +++ b/lib/screens/heir_view_screen.dart @@ -68,6 +68,71 @@ class HeirViewScreen extends StatelessWidget { } } +/// SEC 2026-08-03 (Gemini PASS_TECH-03) — le mot de passe est MASQUÉ par défaut +/// dans la vue héritier, comme partout ailleurs dans l'application. +/// +/// Il s'y affichait en clair dès l'ouverture de l'entrée. L'héritier consulte +/// souvent ce coffre dans un moment et un lieu qu'il n'a pas choisis — étude +/// notariale, hôpital, réunion de famille — c'est-à-dire exactement les +/// circonstances où quelqu'un regarde par-dessus l'épaule. +/// +/// L'incohérence était double : tout le reste de l'app masque les mots de passe +/// derrière un œil, et cet écran est le seul consulté par une personne qui ne +/// connaît pas l'application. Le bouton de copie reste disponible sans avoir à +/// dévoiler quoi que ce soit. +class _HeirPasswordTile extends StatefulWidget { + final Entry entry; + final Future Function(BuildContext, String, String) onCopy; + const _HeirPasswordTile({required this.entry, required this.onCopy}); + + @override + State<_HeirPasswordTile> createState() => _HeirPasswordTileState(); +} + +class _HeirPasswordTileState extends State<_HeirPasswordTile> { + bool _show = false; + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return ListTile( + dense: true, + leading: const Icon(Icons.key_outlined, size: 18), + title: Text( + t.heirViewFieldPassword, + style: const TextStyle(fontSize: 12), + ), + subtitle: SelectableText( + _show ? widget.entry.password : '•' * widget.entry.password.length, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + textWidthBasis: TextWidthBasis.longestLine, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + _show ? Icons.visibility_off : Icons.visibility, + size: 18, + ), + tooltip: _show ? t.unlockHidePassword : t.unlockShowPassword, + onPressed: () => setState(() => _show = !_show), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18), + tooltip: t.heirViewFieldPassword, + onPressed: () => widget.onCopy( + context, + t.heirViewFieldPassword, + widget.entry.password, + ), + ), + ], + ), + ); + } +} + class _EntryTile extends StatelessWidget { final Entry entry; const _EntryTile({required this.entry}); @@ -129,25 +194,7 @@ class _EntryTile extends StatelessWidget { ), ), if (entry.password.isNotEmpty) - ListTile( - dense: true, - leading: const Icon(Icons.key_outlined, size: 18), - title: Text( - t.heirViewFieldPassword, - style: const TextStyle(fontSize: 12), - ), - subtitle: SelectableText( - entry.password, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), - textWidthBasis: TextWidthBasis.longestLine, - ), - trailing: IconButton( - icon: const Icon(Icons.copy, size: 18), - tooltip: t.heirViewFieldPassword, - onPressed: () => - _copy(context, t.heirViewFieldPassword, entry.password), - ), - ), + _HeirPasswordTile(entry: entry, onCopy: _copy), if (entry.url.isNotEmpty) ListTile( dense: true, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ace5a1c..394616c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -679,6 +679,41 @@ class _SettingsScreenState extends State if (mounted) setState(() => _clipboardClear = v); } + /// SEC 2026-08-03 (Gemini PT-001/PT-003) — redemande le mot de passe maître + /// avant une opération irréversible ou exfiltrante. + /// + /// SEC F10 v2.5.2 avait ajouté cette ré-authentification au changement de mot + /// de passe, en désignant précisément la menace : « quiconque disposait d'un + /// accès momentané à une session déverrouillée ». Le raisonnement n'avait été + /// propagé ni à l'export en clair, ni à la suppression du coffre — alors que + /// l'export est PIRE que le changement de mot de passe : il emporte + /// l'intégralité des identifiants, en clair, hors de l'appareil. + /// + /// Le verrouillage automatique par défaut est à 300 s et n'est évalué qu'au + /// retour au premier plan : une application laissée ouverte devant quelqu'un + /// reste ouverte. C'est exactement la fenêtre que ce contrôle referme. + /// + /// Vérifie contre l'emplacement ACTIF : depuis une session leurre, c'est le + /// mot de passe du leurre qui est attendu — rien n'est révélé du principal. + Future _reauthenticate() async { + final t = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final pwd = await showDialog( + context: context, + builder: (_) => const _ReauthDialog(), + ); + if (pwd == null || pwd.isEmpty || !mounted) return false; + final ok = await VaultService().verifyCurrentPassword(pwd); + if (!ok && mounted) { + SnackUtils.showError( + context, + messenger, + t.changePasswordErrorWrongCurrent, + ); + } + return ok; + } + Future _exportVault() async { final t = AppLocalizations.of(context); // H-5 : confirmation explicite avant tout export en clair, et suppression @@ -735,6 +770,9 @@ class _SettingsScreenState extends State }, ); if (confirmed != true || !mounted) return; + // Ré-authentification APRÈS l'avertissement : on ne demande le mot de passe + // qu'à quelqu'un qui a lu et accepté ce que l'export implique. + if (!await _reauthenticate() || !mounted) return; final json = VaultService().exportJson(); final dir = await getTemporaryDirectory(); @@ -1164,6 +1202,11 @@ class _SettingsScreenState extends State ), ); if (ok != true) return; + // SEC 2026-08-03 — la suppression définitive exige le mot de passe maître. + // Sans ce contrôle, un accès momentané à une session ouverte suffisait à + // anéantir le coffre — irréversible, `allowBackup="false"` interdisant + // toute restauration système. + if (!await _reauthenticate() || !mounted) return; // U9 v2.4.4 — feedback haptique sur action destructive ultime. await HapticFeedback.heavyImpact(); // v2.5.4 — plus de refus opaque depuis une session leurre. `deleteVault()` @@ -1778,6 +1821,65 @@ class _PassphraseDialogState extends State<_PassphraseDialog> { } } +/// Demande le mot de passe maître avant une opération sensible. +/// `StatefulWidget` pour disposer proprement le contrôleur et vider son tampon. +class _ReauthDialog extends StatefulWidget { + const _ReauthDialog(); + + @override + State<_ReauthDialog> createState() => _ReauthDialogState(); +} + +class _ReauthDialogState extends State<_ReauthDialog> { + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.clear(); + _ctrl.dispose(); + super.dispose(); + } + + void _submit() { + final v = _ctrl.text; + _ctrl.clear(); + Navigator.pop(context, v); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return AlertDialog( + icon: const Icon(Icons.lock_outline, size: 32), + title: Text(t.reauthTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(t.reauthBody, style: const TextStyle(fontSize: 13)), + const SizedBox(height: 12), + PasswordTextField( + controller: _ctrl, + labelText: t.changePasswordCurrentLabel, + autofocus: true, + onSubmitted: (_) => _submit(), + showPrefixIcon: false, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + _ctrl.clear(); + Navigator.pop(context); + }, + child: Text(t.actionCancel), + ), + FilledButton(onPressed: _submit, child: Text(t.actionContinue)), + ], + ); + } +} + class _ChangePasswordDialog extends StatefulWidget { const _ChangePasswordDialog(); diff --git a/lib/screens/unlock_screen.dart b/lib/screens/unlock_screen.dart index 4363c7f..ed618d5 100644 --- a/lib/screens/unlock_screen.dart +++ b/lib/screens/unlock_screen.dart @@ -205,6 +205,15 @@ class _UnlockScreenState extends State { @override void dispose() { + // SEC 2026-08-03 — effacement du tampon AVANT libération. + // + // Tous les autres champs sensibles de l'app le font depuis B8/B9 v2.3.8 + // (`_PassphraseDialog`, `_ChangePasswordDialog`, `_HeirPasswordDialog`, + // l'écran d'édition d'entrée). Le champ du MOT DE PASSE MAÎTRE — le seul + // secret dont dépendent tous les autres — était le seul à ne pas le faire. + // Une saisie en cours au moment où l'écran est détruit restait dans le + // tampon du contrôleur jusqu'au passage du ramasse-miettes. + _passCtrl.clear(); _passCtrl.dispose(); _lockoutTimer?.cancel(); super.dispose(); @@ -423,19 +432,26 @@ class _UnlockScreenState extends State { await Future.delayed(Duration(milliseconds: delay.clamp(1000, 16000))); if (!mounted) return; } + // SEC 2026-08-03 (Gemini PT-002) — l'essai est persisté AVANT la + // vérification, et non après. + // + // P3-3 v2.2.0 avait bien vu qu'un compteur en RAM se remettait à zéro en + // relançant l'app, et l'avait donc persisté. Mais l'écriture restait + // APRÈS `unlockAsHeir`, c'est-à-dire après une seconde d'Argon2id : fermer + // l'application pendant ce calcul suffisait à annuler l'essai, et le délai + // progressif redevenait contournable exactement comme avant P3-3. Le + // stockage avait été durci, pas le moment de l'écriture. + _heirFailCount++; + try { + await _secureStorage.write( + key: _heirFailCountKey, + value: _heirFailCount.toString(), + ); + } catch (_) {} + final entries = await HeritageService().unlockAsHeir(pwd); if (!mounted) return; if (entries == null) { - _heirFailCount++; - // P3-3 : persiste pour résister à un force-close (sinon l'attaquant - // peut reset le délai progressif en relançant l'app). - // v2.5.0 (F1) : FlutterSecureStorage au lieu de SharedPreferences clair. - try { - await _secureStorage.write( - key: _heirFailCountKey, - value: _heirFailCount.toString(), - ); - } catch (_) {} if (!mounted) return; final t = AppLocalizations.of(context); setState(() { diff --git a/lib/services/heritage_service.dart b/lib/services/heritage_service.dart index 38974fc..d2b6979 100644 --- a/lib/services/heritage_service.dart +++ b/lib/services/heritage_service.dart @@ -354,8 +354,24 @@ class HeritageService { // Inoffensif ici (HMAC-SHA256 32 octets fixes). Ne pas réutiliser pour // des secrets de longueur variable. - Future _deriveKeyV1(String password, Uint8List salt, int iter) { - return compute(pbkdf2Worker, [utf8.encode(password), salt, iter, 64]); + /// SEC 2026-08-03 (Gemini PT-005) — la copie UTF-8 du mot de passe héritier + /// est effacée après l'appel. + /// + /// `compute` transfère une copie à l'isolat de travail, que `pbkdf2Worker` + /// efface bien de son côté — mais l'exemplaire de CE côté-ci restait en + /// mémoire jusqu'au ramasse-miettes. Chemin hérité (instantanés v1), donc + /// rare, mais c'est du matériel de mot de passe. + Future _deriveKeyV1( + String password, + Uint8List salt, + int iter, + ) async { + final pw = utf8.encode(password); + try { + return await compute(pbkdf2Worker, [pw, salt, iter, 64]); + } finally { + SecretBytes.wipe(pw); + } } /// AAD bound to a v2 heir snapshot (anti-downgrade). @@ -389,8 +405,11 @@ class HeritageService { ) async { final saltB64 = base64Encode(salt); final aad = _aadV2(saltB64, params); - final plain = Uint8List.fromList( - utf8.encode(jsonEncode(entries.map((e) => e.toJson()).toList())), + // SEC 2026-08-03 — `utf8.encode` rend déjà un `Uint8List` ; l'envelopper + // créait une seconde copie du coffre EN CLAIR que l'effacement du `finally` + // ne couvrait pas. Voir `KdfService.argon2id` pour le même motif. + final plain = utf8.encode( + jsonEncode(entries.map((e) => e.toJson()).toList()), ); final AeadResult res; try { diff --git a/lib/services/import_export_service.dart b/lib/services/import_export_service.dart index 69a097e..0b71777 100644 --- a/lib/services/import_export_service.dart +++ b/lib/services/import_export_service.dart @@ -408,8 +408,11 @@ class ImportExportService { final aad = Uint8List.fromList( _aadV3(saltB64, KdfParams.owaspMobile2024), ); - final plain = Uint8List.fromList( - utf8.encode(jsonEncode(entries.map((e) => e.toJson()).toList())), + // SEC 2026-08-03 — `utf8.encode` rend déjà un `Uint8List` ; l'envelopper + // créait une seconde copie du coffre EN CLAIR que l'effacement ci-dessous + // ne couvrait pas. Voir `KdfService.argon2id` pour le même motif. + final plain = utf8.encode( + jsonEncode(entries.map((e) => e.toJson()).toList()), ); final AeadResult res; try { @@ -562,12 +565,17 @@ class ImportExportService { // côté API (un futur appelant pourrait s'y fier). if (mac.length != 32) return null; - final key = await compute(pbkdf2Worker, [ - utf8.encode(passphrase), - salt, - iterations, - 64, - ]); + // SEC 2026-08-03 (Gemini PT-004) — la copie UTF-8 de la phrase secrète + // est effacée après l'appel. `compute` en transfère un exemplaire à + // l'isolat, que `pbkdf2Worker` efface bien ; celui de ce côté-ci restait + // en mémoire jusqu'au ramasse-miettes. + final pw = utf8.encode(passphrase); + final Uint8List key; + try { + key = await compute(pbkdf2Worker, [pw, salt, iterations, 64]); + } finally { + SecretBytes.wipe(pw); + } // M-3 : zéroïser key + sublists après usage. final macKey = key.sublist(32); Uint8List? encKeyBytes; diff --git a/lib/services/kdf_service.dart b/lib/services/kdf_service.dart index 63346f8..adee6e7 100644 --- a/lib/services/kdf_service.dart +++ b/lib/services/kdf_service.dart @@ -123,7 +123,15 @@ class KdfService { required Uint8List salt, KdfParams params = KdfParams.owaspMobile2024, }) async { - final pw = Uint8List.fromList(utf8.encode(password)); + // SEC 2026-08-03 (Gemini PT-001) — plus de `Uint8List.fromList(...)`. + // + // `utf8.encode` rend DÉJÀ un `Uint8List`. L'envelopper en créait une + // seconde copie, et seule celle-là était effacée : l'originale, qui + // contient le mot de passe maître en clair, restait en mémoire jusqu'au + // ramasse-miettes. `vault_storage.dart` documente ce piège exact depuis la + // v2.5.x et l'évite — la règle n'avait pas été propagée ici, ni aux quatre + // autres sites du même motif. + final pw = utf8.encode(password); try { return await compute(_argon2idIsolate, [ pw, diff --git a/lib/services/vault_brute_force.dart b/lib/services/vault_brute_force.dart index c138cbb..733b2cf 100644 --- a/lib/services/vault_brute_force.dart +++ b/lib/services/vault_brute_force.dart @@ -99,13 +99,57 @@ extension VaultBruteForce on VaultService { return ((until - now) / 1000).ceil(); } - Future _onUnlockFail() async { + /// SEC 2026-08-03 (Gemini PT-002) — provisionne l'échec AVANT de tenter. + /// + /// `_onUnlockFail` n'est appelé qu'APRÈS la vérification, donc après une à + /// deux secondes d'Argon2id. Il suffit de tuer l'application pendant ce + /// calcul — depuis le sélecteur d'applications récentes, ou par script — pour + /// que l'essai ne soit **jamais comptabilisé**. Répété, cela supprime + /// purement et simplement le verrouillage progressif : il ne reste que le + /// coût d'Argon2id, alors que tout l'intérêt du verrouillage est justement + /// d'ajouter une barrière que le calcul seul n'offre pas. + /// + /// La parade est classique : on écrit l'échec AVANT, et on l'annule + /// seulement si la vérification réussit ([_onUnlockSuccess] efface déjà le + /// compteur). Une interruption laisse donc l'essai compté — fail-closed. + /// + /// ⚠️ Ne déclenche PAS le verrouillage lui-même : seul `_onUnlockFail`, + /// appelé après un échec avéré, arme le compte à rebours. Un utilisateur + /// légitime dont le déverrouillage aboutit ne voit jamais ce compteur. + Future _reserveUnlockAttempt() async { + try { + final s = await VaultService._storage.read( + key: VaultService._failCountKey, + ); + final count = ((int.tryParse(s ?? '0') ?? 0) + 1).clamp(0, 1000); + await VaultService._storage.write( + key: VaultService._failCountKey, + value: count.toString(), + ); + } catch (_) { + /* best-effort : ne doit jamais empêcher une tentative légitime */ + } + } + + /// [alreadyReserved] : l'essai a déjà été provisionné par + /// [_reserveUnlockAttempt] avant la vérification. On se contente alors de + /// lire le compteur pour armer le verrouillage, sans le réincrémenter — sinon + /// chaque échec compterait double. + Future _onUnlockFail({bool alreadyReserved = false}) async { final s = await VaultService._storage.read(key: VaultService._failCountKey); // F6 v2.4.4 — clamp à 1000 max. Avant : un attaquant qui spam `unlock()` // pendant des heures montait le compteur à des dizaines de milliers ; // aucun impact crypto (lockout step toujours plafonné à 30 min via // `_lockoutSteps.length - 1`) mais usure NAND et pollution storage. - final count = ((int.tryParse(s ?? '0') ?? 0) + 1).clamp(0, 1000); + final previous = int.tryParse(s ?? '0') ?? 0; + // Si la réservation a échoué (stockage momentanément indisponible), on lit + // 0 alors qu'un essai vient bel et bien d'échouer. Compter au moins 1 : + // sans ce plancher, une erreur d'écriture désarmerait le verrouillage — + // un repli du mauvais côté, exactement le défaut que SEC F5/F17 a corrigé + // sur le compte à rebours. + final count = alreadyReserved + ? (previous < 1 ? 1 : previous).clamp(0, 1000) + : (previous + 1).clamp(0, 1000); await VaultService._storage.write( key: VaultService._failCountKey, value: count.toString(), diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 500d799..42e1251 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -481,6 +481,12 @@ class VaultService { Future _unlockInternalUnguarded(String masterPassword) async { if (await getLockoutRemaining() != null) return UnlockResult.lockedOut; + // SEC 2026-08-03 (Gemini PT-002) — l'essai est compté AVANT la dérivation, + // pas après. Tuer l'application pendant les ~2 s d'Argon2id ne permet plus + // d'effacer la trace de la tentative. Un déverrouillage réussi remet le + // compteur à zéro (`_onUnlockSuccess`), donc l'utilisateur légitime ne voit + // jamais la différence. + await _reserveUnlockAttempt(); // Déni plausible : on tente UNE PASSE Argon2id sur CHAQUE slot, même si // un slot précédent a matché. Sinon le timing révèle l'existence du // decoy (1× Argon2id = matché primary, 2× = matché decoy ou échec avec @@ -641,7 +647,7 @@ class VaultService { return UnlockResult.success; } _wipeKey(); - await _onUnlockFail(); + await _onUnlockFail(alreadyReserved: true); return UnlockResult.wrongPassword; } From ecfafce12e6b8424e1928b6f3bc4522eff076e57 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 22:31:29 +0200 Subject: [PATCH 08/34] feat(securite): avertissement avant la panique + incitation a la sauvegarde MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux garde-fous nes d'un incident reel du 2026-08-03 : un coffre a ete perdu definitivement. Enchainement : activation du mode panique pour un test → suppression de l'enrolement biometrique (SEC F4, voulu) → mot de passe maitre introuvable → desinstallation. Aucune sauvegarde n'existait. 1. AVERTISSEMENT AVANT LA PANIQUE SEC F4 justifiait la suppression de la biometrie par « le cout pour l'utilisateur legitime est faible, puisque le reenrolement exige de toute facon le mot de passe maitre ». Ce raisonnement suppose que l'utilisateur CONNAIT ce mot de passe — or quelqu'un qui ouvre a l'empreinte tous les jours ne le tape parfois plus depuis des mois. Pour lui, la panique est une porte a sens unique. Un ecran l'annonce desormais, uniquement si la biometrie est active (sinon il n'y a rien a perdre et ce serait du bruit). « Annuler » en position sure et focalise par defaut. 2. INCITATION A LA SAUVEGARDE Rien dans l'application ne suggerait de creer une sauvegarde chiffree, alors que c'est le SEUL moyen de recuperation : ni cloud, ni compte, ni sequestre. - a la creation du coffre : un dialogue pose l'enjeu. On ne propose PAS d'exporter — le coffre vient d'etre cree, il est vide. - sur l'accueil : un bandeau apparait des qu'il y a au moins une entree ET qu'aucune sauvegarde n'a jamais ete faite. Non masquable a dessein : un bandeau qu'on ecarte d'un geste est ecarte le premier jour et jamais revu, ce qui est exactement ce qui a coute un coffre. Il ne part qu'en agissant. - nouveau `BackupReminder` : ne conserve QUE la date, jamais le chemin ni la phrase secrete. Preferences ordinaires et non stockage securise — un horodatage n'est pas un secret et ne revele rien du contenu. - la suppression totale du coffre reinitialise la trace : un coffre recree repart avec le rappel actif, ses futures entrees n'etant couvertes par aucune sauvegarde anterieure. 6 chaines FR/EN ajoutees (parite verifiee : 516 = 516, aucun orphelin). NB d'outillage : les deux premieres tentatives d'ajout de ces chaines ont produit un ARB casse puis un litteral « \n » affiche tel quel — le passage par un heredoc shell mangeait les antislashs. Corrige en ecrivant le script en FICHIER, avec assertions de relecture sur les sauts de ligne. flutter analyze : 0 issue. 139 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/l10n/app_en.arb | 6 +++ lib/l10n/app_fr.arb | 6 +++ lib/screens/home_screen.dart | 76 +++++++++++++++++++++++++++++++ lib/screens/settings_screen.dart | 58 +++++++++++++++++++++++ lib/screens/setup_screen.dart | 33 ++++++++++++++ lib/services/backup_reminder.dart | 45 ++++++++++++++++++ 6 files changed, 224 insertions(+) create mode 100644 lib/services/backup_reminder.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3f66e61..dedab9a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -512,6 +512,12 @@ "settingsDeleteAllDialogBody": "All your entries and the vault will be permanently deleted.", "reauthTitle": "Confirm your identity", "reauthBody": "Enter your master password to continue.", + "panicWarnBiometricTitle": "Biometric unlock will be disabled", + "panicWarnBiometricBody": "Panic mode removes the biometric enrolment — otherwise someone could open your vault by making you place your finger.\n\nAfter activation, ONLY your master password will open the vault. It cannot be recovered by any means.\n\nMake sure you know it before continuing.", + "backupReminderTitle": "Remember your backup", + "backupReminderBody": "Your vault exists only on this phone. No cloud, no account, no recovery: losing the phone or forgetting the master password means losing the data.\n\nAs soon as you have entered your first items, export an encrypted backup (.ptbak) from Settings → Data. It is the only safety net there is.", + "backupBannerText": "No encrypted backup has been created yet — it is the only way to recover your data.", + "backupBannerCta": "Back up", "settingsDeleteAllConfirm": "Delete everything", "exportPlainDialogTitle": "UNENCRYPTED export", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6163f71..51fbee5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -496,6 +496,12 @@ "settingsDeleteAllDialogBody": "Toutes vos entrées et le coffre-fort seront supprimés définitivement.", "reauthTitle": "Confirmez votre identité", "reauthBody": "Saisissez votre mot de passe maître pour continuer.", + "panicWarnBiometricTitle": "La biométrie sera désactivée", + "panicWarnBiometricBody": "Le mode panique supprime l'enrôlement biométrique — sinon quelqu'un pourrait ouvrir votre coffre en vous faisant poser le doigt.\n\nAprès activation, SEUL votre mot de passe maître ouvrira le coffre. Il n'est récupérable par aucun moyen.\n\nAssurez-vous de le connaître avant de continuer.", + "backupReminderTitle": "Pensez à votre sauvegarde", + "backupReminderBody": "Votre coffre n'existe que sur ce téléphone. Ni cloud, ni compte, ni récupération : perdre le téléphone ou oublier le mot de passe maître, c'est perdre les données.\n\nDès vos premières entrées saisies, exportez une sauvegarde chiffrée (.ptbak) depuis Réglages → Données. C'est le seul filet qui existe.", + "backupBannerText": "Aucune sauvegarde chiffrée n'a encore été créée — c'est le seul moyen de récupérer vos données.", + "backupBannerCta": "Sauvegarder", "settingsDeleteAllConfirm": "Tout supprimer", "exportPlainDialogTitle": "Export NON CHIFFRÉ", diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ea7198f..48ac33f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_localizations.dart'; import '../models/category.dart'; import '../models/entry.dart'; +import '../services/backup_reminder.dart'; import '../services/vault_service.dart'; import '../utils/snack_utils.dart'; import 'entry_detail_screen.dart'; @@ -467,6 +468,26 @@ class _HomeScreenState extends State { ), body: Column( children: [ + // 2026-08-03 — rappel de sauvegarde. N'apparaît que si le coffre + // contient au moins une entrée ET qu'aucune sauvegarde chiffrée n'a + // JAMAIS été faite. Disparaît définitivement à la première. + // + // Placé ici plutôt qu'à la création du coffre : à la création il n'y + // a rien à sauvegarder, donc l'invitation serait creuse. Elle n'a de + // sens qu'à partir du moment où il y a quelque chose à perdre. + // + // Non masquable volontairement : il n'y a ni cloud, ni compte, ni + // récupération. Un bandeau qu'on écarte d'un geste serait écarté le + // premier jour et jamais revu — c'est exactement ce qui a coûté un + // coffre le 2026-08-03. + if (VaultService().entries.isNotEmpty) + FutureBuilder( + future: BackupReminder.neverBackedUp, + builder: (context, snap) { + if (snap.data != true) return const SizedBox.shrink(); + return _BackupBanner(onDone: () => setState(() {})); + }, + ), SizedBox( height: 46, child: ListView.separated( @@ -813,3 +834,58 @@ class _EntryCard extends StatelessWidget { ); } } + +/// Rappel de sauvegarde affiché sur l'accueil tant qu'aucune sauvegarde +/// chiffrée n'a jamais été créée et que le coffre contient des entrées. +/// +/// Ajouté le 2026-08-03, après la perte définitive d'un coffre faute de +/// sauvegarde. Le texte dit ce qui est vrai, sans dramatiser : il n'existe +/// aucun autre moyen de récupération. +/// +/// Le bouton mène aux Réglages, où vit l'export — plutôt que de dupliquer le +/// flux d'export ici. Au retour, l'accueil se reconstruit et le bandeau +/// disparaît si la sauvegarde a été faite. +class _BackupBanner extends StatelessWidget { + final VoidCallback onDone; + const _BackupBanner({required this.onDone}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final t = AppLocalizations.of(context); + return Container( + margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.error.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + Icon(Icons.backup_outlined, size: 20, color: cs.error), + const SizedBox(width: 10), + Expanded( + child: Text( + t.backupBannerText, + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 4), + TextButton( + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SettingsScreen(onChanged: onDone), + ), + ); + onDone(); + }, + child: Text(t.backupBannerCta), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 394616c..74423bf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/entry.dart'; import '../main.dart' show prefKeyScreenshotProtection; import '../services/anti_phishing_service.dart'; +import '../services/backup_reminder.dart'; import '../services/clipboard_service.dart'; import '../services/heritage_service.dart'; import '../services/import_export_service.dart'; @@ -553,6 +554,52 @@ class _SettingsScreenState extends State Future _triggerPanic() async { final t = AppLocalizations.of(context); + // SEC 2026-08-03 — avertissement AVANT la panique, ajouté après un incident + // réel : le mode panique a été activé pour un test, il a supprimé + // l'enrôlement biométrique (SEC F4, voulu), et le mot de passe maître ne + // revenait plus. Coffre définitivement perdu. + // + // SEC F4 justifiait la suppression par « le coût pour l'utilisateur + // légitime est faible, puisque le réenrôlement exige de toute façon le mot + // de passe maître ». Ce raisonnement suppose que l'utilisateur CONNAÎT ce + // mot de passe. Quelqu'un qui ouvre à l'empreinte tous les jours ne le tape + // parfois plus depuis des mois : pour lui, la panique est une porte à sens + // unique. + // + // On n'affiche cet écran que si la biométrie est réellement active — sinon + // il n'y a rien à perdre et l'avertissement ne serait que du bruit. + if (_biometricEnabled) { + final compris = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + final cs = Theme.of(ctx).colorScheme; + return AlertDialog( + icon: Icon(Icons.fingerprint, size: 36, color: cs.error), + title: Text(t.panicWarnBiometricTitle), + content: SingleChildScrollView( + child: Text( + t.panicWarnBiometricBody, + style: const TextStyle(fontSize: 13), + ), + ), + actions: [ + TextButton( + autofocus: true, + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.actionCancel), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: cs.error), + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.actionContinue), + ), + ], + ); + }, + ); + if (compris != true || !mounted) return; + } final confirm = await showDialog( context: context, builder: (ctx) { @@ -911,6 +958,11 @@ class _SettingsScreenState extends State _shredStaleExports(dir); final file = File('${dir.path}/pass_tech_$date.ptbak'); await file.writeAsString(content); + // 2026-08-03 — trace de la sauvegarde. C'est ce qui fait disparaître le + // rappel de l'accueil. Enregistré dès que le fichier est écrit, sans + // attendre l'issue du partage : le fichier existe, l'utilisateur a fait + // sa part. Seule la DATE est conservée, jamais le chemin ni la phrase. + await BackupReminder.markBackupDone(); if (!mounted) return; Navigator.of(context).pop(); // close progress try { @@ -1215,6 +1267,12 @@ class _SettingsScreenState extends State // de création après un `decoyOnly` laisserait créer un coffre par-dessus // le principal, qui existe toujours. On revient donc au déverrouillage. final outcome = await VaultService().deleteVault(); + // Un coffre recréé après une suppression totale doit repartir avec le + // rappel actif : ses futures entrées ne seront couvertes par aucune des + // sauvegardes précédentes. + if (outcome == VaultDeleteOutcome.fullWipe) { + await BackupReminder.reset(); + } if (!mounted) return; nav.pushAndRemoveUntil( MaterialPageRoute( diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 539c51c..99882b4 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -63,6 +63,39 @@ class _SetupScreenState extends State { _pass1.clear(); _pass2.clear(); if (!mounted) return; + + // 2026-08-03 — l'utilisateur est prévenu, dès la création, qu'aucune + // récupération n'existe et que la sauvegarde chiffrée est le seul filet. + // + // Ajouté après la perte définitive d'un coffre : mot de passe maître + // oublié, biométrie supprimée par le mode panique, aucune sauvegarde. Rien + // dans l'application ne l'avait jamais suggéré. + // + // On ne PROPOSE pas d'exporter ici : le coffre vient d'être créé, il est + // vide, une sauvegarde n'aurait aucun contenu. Cet écran pose l'enjeu ; le + // rappel qui incite réellement à agir vit sur l'accueil et n'apparaît + // qu'une fois qu'il y a quelque chose à perdre. + final t2 = AppLocalizations.of(context); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.backup_outlined, size: 36), + title: Text(t2.backupReminderTitle), + content: SingleChildScrollView( + child: Text( + t2.backupReminderBody, + style: const TextStyle(fontSize: 13), + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(ctx), + child: Text(t2.actionOk), + ), + ], + ), + ); + if (!mounted) return; Navigator.of( context, ).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen())); diff --git a/lib/services/backup_reminder.dart b/lib/services/backup_reminder.dart new file mode 100644 index 0000000..c0c7a6c --- /dev/null +++ b/lib/services/backup_reminder.dart @@ -0,0 +1,45 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Suivi de la dernière sauvegarde chiffrée `.ptbak`. +/// +/// Ajouté le 2026-08-03 après la perte définitive d'un coffre : mot de passe +/// maître oublié, biométrie supprimée par le mode panique, **et aucune +/// sauvegarde**. Rien dans l'application ne suggérait d'en faire une, alors que +/// c'est le SEUL moyen de récupération qui existe — il n'y a ni cloud, ni +/// compte, ni séquestre de clé. +/// +/// Volontairement dans les préférences ordinaires et non dans le stockage +/// sécurisé : un horodatage de sauvegarde n'est pas un secret, et le placer +/// dans le stockage chiffré le rendrait illisible depuis l'écran d'accueil sans +/// bénéfice. Il ne révèle rien du contenu du coffre. +/// +/// ⚠️ On n'enregistre QUE la date, jamais le chemin ni la phrase secrète. +class BackupReminder { + BackupReminder._(); + + static const _lastBackupKey = 'pt_last_backup_ms'; + + /// Appelé après un export `.ptbak` réussi. + static Future markBackupDone() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_lastBackupKey, DateTime.now().millisecondsSinceEpoch); + } + + /// Vrai si aucune sauvegarde chiffrée n'a jamais été enregistrée. + /// + /// Sciemment binaire plutôt que « sauvegarde trop ancienne » : un rappel qui + /// revient périodiquement devient un bandeau qu'on ne lit plus. Celui-ci + /// disparaît définitivement à la première sauvegarde, donc quand il est là, + /// il veut dire quelque chose. + static Future get neverBackedUp async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_lastBackupKey) == null; + } + + /// Efface la trace — utilisé par la suppression complète du coffre, pour + /// qu'un coffre recréé reparte avec le rappel actif. + static Future reset() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_lastBackupKey); + } +} From f086347267cd3b896b7c91100541f9f12d9f2658 Mon Sep 17 00:00:00 2001 From: Pat Date: Mon, 3 Aug 2026 22:48:33 +0200 Subject: [PATCH 09/34] fix(ux): boucle d'invite biometrique au lancement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale en usage reel : au demarrage, annuler l'invite biometrique la faisait revenir aussitot, en boucle, sans jamais laisser saisir le mot de passe maitre. Le bouton Retour n'y changeait rien — l'application etait inutilisable pour quiconque annule l'invite. ENCHAINEMENT L'invite est un dialogue systeme : elle met l'application en arriere-plan. A l'annulation, on revient au premier plan, `_handleLifecycle` constate que le coffre est ferme et POUSSE un nouvel ecran de deverrouillage avec `pushAndRemoveUntil`, ce qui VIDE la pile — d'ou le Retour sans effet. Le nouvel ecran relance l'invite depuis son `initState`, et la boucle se referme. Ce bloc de `main.dart` vient de SEC F19 v2.5.4 et sert a RAMENER vers le deverrouillage depuis un autre ecran apres un verrouillage automatique. Il n'avait pas prevu le cas ou l'ecran cible est DEJA celui qui est affiche — encore le meme motif que le reste de la journee. CORRECTIF, a deux niveaux, chacun suffisant seul : 1. `UnlockScreenState.estAffiche` : `main.dart` ne pousse plus un ecran de deverrouillage quand il y en a deja un. Benefice annexe : une saisie en cours n'est plus effacee au retour au premier plan. 2. `_inviteBioDejaTentee` : l'invite automatique n'est tentee qu'une fois par cycle de verrouillage. Annuler est une intention claire — « je veux taper mon mot de passe » — et elle est desormais respectee. Le bouton empreinte reste disponible pour relancer volontairement, et le drapeau est reinitialise par un deverrouillage reussi, pour que le cycle suivant repropose la biometrie. Au passage : le libelle du champ 2FA promettait encore « ou scanner QR », alors que le scanner a ete retire avec Google ML Kit. Remplace par « Collez l'URI otpauth:// ou la cle Base32 », FR et EN. flutter analyze : 0 issue. 139 tests verts. Parite i18n 516 = 516. Co-Authored-By: Claude Opus 5 (1M context) --- lib/l10n/app_en.arb | 4 +-- lib/l10n/app_fr.arb | 4 +-- lib/main.dart | 18 +++++++++++ lib/screens/unlock_screen.dart | 56 ++++++++++++++++++++++++++++++++-- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dedab9a..c4a30bb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -323,8 +323,8 @@ "entryEditFieldUrlOptional": "URL (optional)", "entryEditHintUrl": "https://…", "entryEditField2faOptional": "2FA code — TOTP secret (optional)", - "entryEditHint2fa": "Base32 secret key or scan QR", - "entryEditHelper2fa": "Generates 6-digit 2FA codes", + "entryEditHint2fa": "Paste the otpauth:// URI or the Base32 key", + "entryEditHelper2fa": "Generates 6-digit codes. Paste the otpauth:// URI shown below the service's QR code — the secret is extracted automatically.", "entryEditFieldNotesOptional": "Notes (optional)", "entryEditHintNotes": "Additional information…", "entryEditFieldContent": "Content", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 51fbee5..b721c97 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -312,8 +312,8 @@ "entryEditFieldUrlOptional": "URL (optionnel)", "entryEditHintUrl": "https://…", "entryEditField2faOptional": "Code 2FA — secret TOTP (optionnel)", - "entryEditHint2fa": "Clé secrète Base32 ou scanner QR", - "entryEditHelper2fa": "Génère des codes 2FA à 6 chiffres", + "entryEditHint2fa": "Collez l'URI otpauth:// ou la clé Base32", + "entryEditHelper2fa": "Génère des codes à 6 chiffres. Collez l'URI otpauth:// affichée sous le QR code du service — le secret en est extrait automatiquement.", "entryEditFieldNotesOptional": "Notes (optionnel)", "entryEditHintNotes": "Informations supplémentaires…", "entryEditFieldContent": "Contenu", diff --git a/lib/main.dart b/lib/main.dart index 384bae5..db0fda8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -288,6 +288,24 @@ class _PassTechAppState extends State with WidgetsBindingObserver { // affiché est le bon, et le pousser de nouveau effacerait une saisie // en cours. if (!await VaultService().vaultExists) return; + // UX 2026-08-03 — ne pas empiler un écran de déverrouillage sur un + // écran de déverrouillage. + // + // Défaut signalé en usage réel : au lancement, annuler l'invite + // biométrique la faisait revenir en boucle, sans jamais laisser saisir + // le mot de passe maître, et le bouton Retour n'y changeait rien. + // + // L'invite est un dialogue système : elle met l'application en + // arrière-plan. À l'annulation, on repasse ici, le coffre est fermé, et + // ce bloc poussait un NOUVEL écran en VIDANT la pile — d'où le Retour + // sans effet. Le nouvel écran relançait l'invite depuis son `initState`, + // et la boucle se refermait. + // + // Ce bloc n'a de sens que pour RAMENER vers le déverrouillage depuis un + // autre écran (accueil, réglages) après un verrouillage automatique. Si + // l'écran est déjà là, il n'y a rien à faire — et surtout pas le + // reconstruire, ce qui effacerait une saisie en cours. + if (UnlockScreenState.estAffiche) return; _navigatorKey.currentState?.pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const UnlockScreen()), (_) => false, diff --git a/lib/screens/unlock_screen.dart b/lib/screens/unlock_screen.dart index ed618d5..3356d14 100644 --- a/lib/screens/unlock_screen.dart +++ b/lib/screens/unlock_screen.dart @@ -18,10 +18,42 @@ class UnlockScreen extends StatefulWidget { const UnlockScreen({super.key}); @override - State createState() => _UnlockScreenState(); + State createState() => UnlockScreenState(); } -class _UnlockScreenState extends State { +/// Rendu public (et non `_UnlockScreenState`) pour que `main.dart` puisse +/// interroger [estAffiche] avant de pousser un nouvel écran de déverrouillage. +class UnlockScreenState extends State { + /// UX 2026-08-03 — nombre d'écrans de déverrouillage vivants. + /// + /// Lu par `main.dart` avant de pousser un écran de déverrouillage au retour + /// au premier plan : sans ce compteur, il en empilait un NOUVEAU alors qu'un + /// autre était déjà affiché, en écrasant la pile au passage. + static int _instancesVivantes = 0; + + /// Vrai si un écran de déverrouillage est déjà à l'écran. + static bool get estAffiche => _instancesVivantes > 0; + + /// UX 2026-08-03 — l'invite biométrique automatique n'est tentée qu'UNE fois + /// par cycle de verrouillage. + /// + /// Défaut signalé en usage réel : au lancement, annuler l'invite biométrique + /// la faisait revenir aussitôt, en boucle, sans jamais laisser saisir le mot + /// de passe maître — et le bouton Retour n'y changeait rien. + /// + /// Enchaînement : l'invite est un dialogue système, elle met l'application en + /// arrière-plan. À l'annulation, l'application revient au premier plan, le + /// cycle de vie constate que le coffre est fermé et POUSSE un nouvel écran de + /// déverrouillage en vidant la pile. Ce nouvel écran relance l'invite dans + /// son `initState`, et ainsi de suite. + /// + /// Annuler l'invite est une intention claire : « je veux taper mon mot de + /// passe ». On la respecte. Le bouton empreinte reste disponible pour la + /// relancer volontairement, et le drapeau est remis à zéro par un + /// déverrouillage réussi, pour que le cycle suivant reproposeà nouveau la + /// biométrie. + static bool _inviteBioDejaTentee = false; + final _passCtrl = TextEditingController(); bool _loading = false; String? _error; @@ -66,6 +98,7 @@ class _UnlockScreenState extends State { @override void initState() { super.initState(); + _instancesVivantes++; _heirOptionFuture = HeritageService().shouldShowHeirOption(); _disguisedFuture = PanicService.isDisguised(); _checkLockout(); @@ -216,6 +249,7 @@ class _UnlockScreenState extends State { _passCtrl.clear(); _passCtrl.dispose(); _lockoutTimer?.cancel(); + _instancesVivantes--; super.dispose(); } @@ -245,7 +279,17 @@ class _UnlockScreenState extends State { // `mounted` requis : `_tryBiometric` démarre par un setState inconditionnel. // Si l'écran est disposé pendant les await de _checkBiometric, l'appel // provoquerait « setState after dispose ». - if (enabled && _lockoutRemaining == null && mounted) _tryBiometric(); + // + // UX 2026-08-03 — `_inviteBioDejaTentee` casse la boucle d'invite décrite + // sur ce drapeau. Une annulation ne doit plus jamais relancer l'invite + // toute seule ; c'est au bouton empreinte de le faire, sur geste explicite. + if (enabled && + _lockoutRemaining == null && + mounted && + !_inviteBioDejaTentee) { + _inviteBioDejaTentee = true; + _tryBiometric(); + } } Future _tryBiometric() async { @@ -274,6 +318,9 @@ class _UnlockScreenState extends State { if (!mounted) return; switch (result) { case UnlockResult.success: + // UX 2026-08-03 — le coffre s'ouvre : le prochain cycle de + // verrouillage aura de nouveau droit à l'invite automatique. + _inviteBioDejaTentee = false; // Bio = forcément primary (cf. saveBiometricKey), markActive OK await HeritageService().markActive(); if (!mounted) return; @@ -358,6 +405,9 @@ class _UnlockScreenState extends State { if (!mounted) return; switch (result) { case UnlockResult.success: + // UX 2026-08-03 — idem : ouverture réussie par mot de passe, on + // réarme l'invite biométrique pour le cycle suivant. + _inviteBioDejaTentee = false; // Marque l'utilisateur comme actif uniquement si on est sur PRIMARY. // Le decoy ne reset pas le timer héritage (sinon un attaquant qui // force l'ouverture du leurre prolongerait la vie du dead-man). From fd86f503c4a4ed3c2cc619484aedcafd71f35039 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 11:21:14 +0200 Subject: [PATCH 10/34] fix(sec): la vue heritier n'etait plus refermee au retour au premier plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression introduite par le correctif de la boucle d'invite biometrique (f086347). Trouvee en relisant mes propres correctifs. Le garde `UnlockScreenState.estAffiche` ne verifiait que l'EXISTENCE de l'ecran de deverrouillage, pas qu'il soit au premier plan. Or `_unlockAsHeir` empile `HeirViewScreen` PAR-DESSUS lui, et cette vue affiche les entrees dechiffrees de l'instantane d'heritage. Le garde repondait donc « ecran present, rien a faire » et laissait cette vue ouverte au retour au premier plan, alors que le comportement d'avant la refermait. `popUntil((r) => r.isFirst)` traite les deux cas : route au-dessus => fermee ; pas de route au-dessus => appel sans effet, donc ni boucle d'invite, ni saisie effacee. `isFirst` designe bien l'ecran voulu, `SplashGate` RENVOYANT l'ecran de deverrouillage comme widget enfant au lieu de le pousser. flutter analyze : 0 issue. 139 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/main.dart | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index db0fda8..71ebb63 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -305,7 +305,28 @@ class _PassTechAppState extends State with WidgetsBindingObserver { // autre écran (accueil, réglages) après un verrouillage automatique. Si // l'écran est déjà là, il n'y a rien à faire — et surtout pas le // reconstruire, ce qui effacerait une saisie en cours. - if (UnlockScreenState.estAffiche) return; + if (UnlockScreenState.estAffiche) { + // SEC 2026-08-04 — on REVIENT à l'écran de déverrouillage au lieu de + // le reconstruire. + // + // Un simple `return` ici — première version du correctif — était une + // régression : l'écran de déverrouillage existe encore SOUS la vue + // héritier, qui affiche les entrées déchiffrées de l'instantané. Le + // garde voyait « écran présent, rien à faire » et laissait donc cette + // vue ouverte au retour au premier plan, alors que le comportement + // d'avant la refermait. + // + // `popUntil(isFirst)` traite les deux cas d'un coup : s'il y a une + // route au-dessus (vue héritier, ou toute autre à venir), elle est + // fermée ; s'il n'y en a pas, l'appel ne fait rien — donc ni boucle + // d'invite biométrique, ni saisie effacée. + // + // `isFirst` désigne bien l'écran voulu : `SplashGate` RENVOIE + // l'écran de déverrouillage comme widget enfant plutôt que de le + // pousser, il n'y a donc qu'une route à la racine. + _navigatorKey.currentState?.popUntil((r) => r.isFirst); + return; + } _navigatorKey.currentState?.pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const UnlockScreen()), (_) => false, From 249920d39b92656f4de9ac2bbf62423d91e106cb Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 11:26:06 +0200 Subject: [PATCH 11/34] feat(ux): annoncer la desactivation de la biometrie apres changement de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale par Patrice en usage reel : apres avoir change son mot de passe maitre, le bouton empreinte avait disparu au lancement suivant, sans explication. Le comportement est CORRECT et necessaire : l'enveloppe biometrique scelle l'ANCIENNE cle finale, elle devient inutilisable des que la cle est rederivee. `changeMasterPassword` supprime donc l'enrolement (vault_setup.dart, slot primary uniquement — sur le leurre, le supprimer trahirait son existence). Ce qui manquait, c'est de le DIRE. L'ecran affichait « mot de passe change » et rien d'autre. C'est le meme motif que le mode panique corrige la veille : un effet de bord indispensable a la securite, mais silencieux, qui laisse l'utilisateur devant un comportement incomprehensible. Le message n'apparait que si la biometrie etait reellement active, et l'etat est releve AVANT l'appel — apres, l'information n'existe plus. flutter analyze : 0 issue. 139 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/screens/settings_screen.dart | 27 ++++++++++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c4a30bb..3f60bb8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -574,6 +574,7 @@ "changePasswordErrorMin": "Minimum 12 characters", "changePasswordErrorMismatch": "Passwords do not match", "changePasswordCta": "Change", + "changePasswordDoneBiometricReset": "Password changed. Biometric unlock has been disabled — re-enable it in Settings.", "changePasswordDoneSnack": "Password changed ✓", "passphraseDialogConfirmHelper": "Pick a passphrase to encrypt the backup. You will need it to restore.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b721c97..248c7e2 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -558,6 +558,7 @@ "changePasswordErrorMin": "Minimum 12 caractères", "changePasswordErrorMismatch": "Les mots de passe ne correspondent pas", "changePasswordCta": "Modifier", + "changePasswordDoneBiometricReset": "Mot de passe changé. Le déverrouillage biométrique a été désactivé — réactivez-le dans Réglages.", "changePasswordDoneSnack": "Mot de passe modifié ✓", "passphraseDialogConfirmHelper": "Choisissez une passphrase pour chiffrer la sauvegarde. Vous en aurez besoin pour restaurer.", diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 74423bf..076bbcb 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1201,6 +1201,10 @@ class _SettingsScreenState extends State // le save réussi, le mot de passe était changé mais l'UI le croyait échoué. // Aligné sur les autres opérations Réglages (export/héritage) déjà en // try/catch avec pop du progress en cas d'erreur. + // UX 2026-08-04 — relevé AVANT l'appel : `changeMasterPassword` supprime + // l'enrôlement biométrique en cours de route, on ne pourrait plus savoir + // après coup s'il existait. + final bioEtaitActive = _biometricEnabled; try { await VaultService().changeMasterPassword( result.fresh, @@ -1208,7 +1212,28 @@ class _SettingsScreenState extends State ); if (!mounted) return; nav.pop(); // close progress dialog - SnackUtils.showInfo(messenger, t.changePasswordDoneSnack); + // UX 2026-08-04 — on ANNONCE la désactivation de la biométrie. + // + // Le changement de mot de passe supprime l'enrôlement biométrique, et + // c'est nécessaire : l'enveloppe biométrique scelle l'ANCIENNE clé + // finale, elle est inutilisable après la rotation. Mais l'app ne le + // disait pas — elle affichait « mot de passe changé » et rien d'autre. + // L'utilisateur relançait l'application, ne trouvait plus le bouton + // empreinte, et n'avait aucun moyen de comprendre pourquoi. + // + // Même motif que le mode panique corrigé la veille : un effet de bord + // indispensable à la sécurité, mais silencieux. Le message n'apparaît + // que si la biométrie était réellement active — sinon il n'apprendrait + // rien à personne. + SnackUtils.showInfo( + messenger, + bioEtaitActive + ? t.changePasswordDoneBiometricReset + : t.changePasswordDoneSnack, + duration: bioEtaitActive + ? const Duration(seconds: 6) + : const Duration(seconds: 3), + ); setState(() => _biometricEnabled = false); } on StateError catch (e) { // SEC F10 v2.5.2 — mot de passe actuel incorrect : message dédié plutôt From 65ff3ff93e91081332800b21c6a370d84a03af9b Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 11:29:09 +0200 Subject: [PATCH 12/34] feat(ui): bandeaux d'information au bleu de la marque Le fond etait le gris tres sombre que Material pose par defaut (`inverseSurface`). En theme clair il jurait avec le reste de l'application. Pose dans le THEME et non dans `SnackUtils` : les ~28 bandeaux du depot en heritent d'un coup, y compris la couleur du bouton d'action, qui serait devenue illisible sur fond bleu. Aucun site d'appel a modifier. Les couleurs viennent du SCHEMA de couleurs, jamais codees en dur. En theme clair, fond `primary` fonce et texte blanc ; en sombre, fond #58A6FF clair et texte #0D1117. Coder le bleu du damier en dur aurait reproduit l'erreur des `Colors.grey` corrigee en v2.4.4 (contraste ~3:1 en sombre, sous l'exigence AA). `_lightTheme` construit desormais son `ColorScheme` explicitement au lieu de passer par `colorSchemeSeed`, pour pouvoir en reutiliser les couleurs dans les themes de composants. Strictement equivalent : `colorSchemeSeed` fait exactement cet appel. Les messages d'ERREUR gardent leur fond rouge : ils posent explicitement `errorContainer`, ce qui ecrase le theme. Une erreur ne doit pas ressembler a une confirmation. Corollaire : l'icone de succes etait dessinee en `cs.primary`, la couleur qui devient le FOND. Passee en `cs.onPrimary`, sans quoi elle devenait invisible. flutter analyze : 0 issue. Co-Authored-By: Claude Opus 5 (1M context) --- lib/main.dart | 48 ++++++++++++++++++++++++++++++-------- lib/utils/snack_utils.dart | 5 +++- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 71ebb63..cda7138 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -400,15 +400,35 @@ class _PassTechAppState extends State with WidgetsBindingObserver { } } -ThemeData _lightTheme() => ThemeData( - useMaterial3: true, - colorSchemeSeed: const Color(0xFF1F6FEB), - brightness: Brightness.light, - // U11 v2.4.4 — snack flottant par défaut (cohérent avec SnackUtils + - // les ScaffoldMessenger inline qui n'avaient pas `behavior:floating`). - // Aligné PDF Tech v1.12.4 U2. - snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), -); +ThemeData _lightTheme() { + // Le schéma est construit ICI plutôt que via `colorSchemeSeed`, pour pouvoir + // en réutiliser les couleurs dans les thèmes de composants ci-dessous. + // Strictement équivalent : `colorSchemeSeed` fait exactement cet appel. + final cs = ColorScheme.fromSeed(seedColor: const Color(0xFF1F6FEB)); + return ThemeData( + useMaterial3: true, + colorScheme: cs, + brightness: Brightness.light, + // U11 v2.4.4 — snack flottant par défaut (cohérent avec SnackUtils + + // les ScaffoldMessenger inline qui n'avaient pas `behavior:floating`). + // Aligné PDF Tech v1.12.4 U2. + // + // UI 2026-08-04 — fond BLEU de la marque au lieu du gris très sombre que + // Material pose par défaut (`inverseSurface`), qui jurait avec le reste de + // l'application en thème clair. + // + // Les couleurs viennent du schéma, jamais codées en dur : c'est ce qui + // garantit le contraste dans les deux thèmes. `onPrimary` est calculé par + // Material pour être lisible sur `primary` — l'écrire à la main + // reproduirait l'erreur des `Colors.grey` corrigée en v2.4.4. + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: cs.primary, + contentTextStyle: TextStyle(color: cs.onPrimary), + actionTextColor: cs.onPrimary, + ), + ); +} ThemeData _darkTheme() { const bg = Color(0xFF0D1117); @@ -468,6 +488,14 @@ ThemeData _darkTheme() { listTileTheme: const ListTileThemeData(tileColor: surface), dividerColor: border, // U11 v2.4.4 — snack flottant sur dark theme. - snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + // UI 2026-08-04 — même fond bleu qu'en thème clair. Ici `blue` (#58A6FF) + // est clair et le texte sombre (#0D1117), l'inverse du thème clair : c'est + // exactement ce que le passage par le schéma de couleurs garantit. + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: blue, + contentTextStyle: TextStyle(color: Color(0xFF0D1117)), + actionTextColor: Color(0xFF0D1117), + ), ); } diff --git a/lib/utils/snack_utils.dart b/lib/utils/snack_utils.dart index 0c10793..b5845a3 100644 --- a/lib/utils/snack_utils.dart +++ b/lib/utils/snack_utils.dart @@ -67,7 +67,10 @@ abstract final class SnackUtils { SnackBar( content: Row( children: [ - Icon(Icons.check_circle, color: cs.primary, size: 18), + // UI 2026-08-04 — `cs.onPrimary` et non `cs.primary` : depuis que + // le fond du bandeau EST `cs.primary`, une icône de cette même + // couleur serait invisible. + Icon(Icons.check_circle, color: cs.onPrimary, size: 18), const SizedBox(width: 8), Expanded(child: Text(text)), ], From fdc8b57d661cb53a07a7613dbd521866107016d3 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 11:42:26 +0200 Subject: [PATCH 13/34] sec: regle de mot de passe unique appliquee aux 5 points d'entree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale par Patrice : « il m'a demande des symboles et des chiffres ». En verifiant, le vrai defaut etait ailleurs et plus grave. CE QUI N'ALLAIT PAS `PasswordStrengthService.score` n'etait appele QUE dans `setup_screen.dart`. Les quatre autres points d'entree ne verifiaient que la LONGUEUR : - changement du mot de passe maitre : 12 caracteres, point - phrase secrete d'une `.ptbak` : 12 caracteres, point - mot de passe du coffre leurre : 12 caracteres, point - mot de passe heritier : 12 caracteres, point On pouvait donc creer un coffre avec un mot de passe solide, puis le remplacer par `aaaaaaaaaaaa` : accepte sans broncher, ni repetition detectee ni mot de passe courant rejete. La porte d'entree etait gardee, aucune des autres portes de la meme maison ne l'etait — et c'est la porte de service qui permet de DEGRADER un coffre existant. Le cas le plus grave etait la phrase du `.ptbak` : seul artefact NON lie au materiel, donc seul attaquable hors ligne depuis une copie de fichier. Par ailleurs `setupDecoyVault` n'avait AUCUNE garde au niveau du service, alors que son jumeau `setupOrUpdateSnapshot` en avait une depuis toujours. CE QUI CHANGE `PasswordPolicy` : une regle, cinq appelants, plus deux gardes de service. - 12 caracteres minimum (inchange, decision de Patrice) - AUCUNE obligation de symbole, de chiffre ni de casse. Le NIST le deconseille depuis SP 800-63B : ces regles poussent vers `Motdepasse2024!`. A 12 caracteres, les minuscules seules donnent deja 56 bits. - seuil d'entropie conserve (48 bits), qui rejette repetitions, suites et mots de passe courants - au moins 5 caracteres DISTINCTS — comble une limite decouverte en ecrivant les tests : `entropyBits` compte le pool de CLASSES, pas les caracteres employes, donc `abababababab` etait credite de 56 bits au lieu d'une douzaine. Ce controle est pose dans `PasswordPolicy` et NON dans `PasswordStrengthService`, dont le barème alimente aussi l'indicateur de force et l'ecran d'audit : en changer le calcul modifierait retroactivement le verdict rendu sur les entrees deja enregistrees. Message d'erreur corrige : « variez majuscules, chiffres, symboles » laissait croire a une obligation qui n'a jamais existe. Remplace par une consigne vraie et actionnable. `setupErrorWeak`, devenue orpheline, est supprimee. NON-REGRESSION VERIFIEE - `PasswordPolicy` n'est appelee sur AUCUN chemin de deverrouillage : un mot de passe existant qui ne respecterait pas la nouvelle regle ouvre toujours le coffre. Aucun risque d'enfermement. - l'import d'une `.ptbak` passe par `confirm: false`, hors du controle : une vieille sauvegarde a phrase courte reste restaurable. 14 tests ajoutes, dont sept verifient qu'une phrase de passe HONNETE est bien acceptee — une regle trop stricte est un defaut, pas une protection. Suite complete : 153 tests verts. flutter analyze : 0 issue. Co-Authored-By: Claude Opus 5 (1M context) --- lib/l10n/app_en.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/screens/settings_screen.dart | 43 +++++++++-- lib/screens/setup_screen.dart | 21 ++++-- lib/services/heritage_service.dart | 13 +++- lib/services/password_policy.dart | 107 +++++++++++++++++++++++++++ lib/services/vault_service.dart | 18 +++++ test/password_policy_test.dart | 112 +++++++++++++++++++++++++++++ 8 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 lib/services/password_policy.dart create mode 100644 test/password_policy_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3f60bb8..46121bd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -66,7 +66,7 @@ "setupCreateCta": "Create vault", "setupEncrypting": "Encrypting…", "setupErrorMin": "Minimum 12 characters", - "setupErrorWeak": "Password too weak — mix uppercase, digits, symbols", + "passwordTooWeak": "Password too guessable — avoid repetition, sequences and common words. Make it longer rather than adding symbols.", "setupErrorMismatch": "Passwords do not match", "setupWarning": "This password cannot be recovered. If you forget it, your data will be inaccessible.", "strengthWeak": "Weak", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 248c7e2..fbc971c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -66,7 +66,7 @@ "setupCreateCta": "Créer le coffre-fort", "setupEncrypting": "Chiffrement en cours…", "setupErrorMin": "Minimum 12 caractères", - "setupErrorWeak": "Mot de passe trop faible — variez majuscules, chiffres, symboles", + "passwordTooWeak": "Mot de passe trop devinable — évitez les répétitions, les suites et les mots courants. Allongez-le plutôt que d'ajouter des symboles.", "setupErrorMismatch": "Les mots de passe ne correspondent pas", "setupWarning": "Ce mot de passe ne peut pas être récupéré. Si vous l'oubliez, vos données seront inaccessibles.", "strengthWeak": "Faible", diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 076bbcb..d8701c1 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -14,6 +14,7 @@ import '../models/entry.dart'; import '../main.dart' show prefKeyScreenshotProtection; import '../services/anti_phishing_service.dart'; import '../services/backup_reminder.dart'; +import '../services/password_policy.dart'; import '../services/clipboard_service.dart'; import '../services/heritage_service.dart'; import '../services/import_export_service.dart'; @@ -1884,9 +1885,25 @@ class _PassphraseDialogState extends State<_PassphraseDialog> { return; } if (widget.confirm) { - if (_ctrl1.text.length < 12) { - setState(() => _error = t.passphraseErrorMin); - return; + // SEC 2026-08-04 — ce dialogue sert la phrase secrète d'une + // sauvegarde `.ptbak`, le mot de passe du coffre leurre ET celui + // de l'héritier. Il ne vérifiait que la longueur : `aaaaaaaaaaaa` + // passait. Le cas du `.ptbak` est le plus grave — c'est le seul + // fichier NON lié au matériel, donc le seul attaquable hors ligne + // depuis une simple copie. + // + // `confirm: false` (restauration d'une sauvegarde) ne passe pas + // par ici : on y SAISIT une phrase existante, il n'y a rien à + // valider. + switch (PasswordPolicy.check(_ctrl1.text)) { + case PasswordRejection.tooShort: + setState(() => _error = t.passphraseErrorMin); + return; + case PasswordRejection.tooWeak: + setState(() => _error = t.passwordTooWeak); + return; + case null: + break; } if (_ctrl1.text != _ctrl2.text) { setState(() => _error = t.passphraseErrorMismatch); @@ -2032,9 +2049,23 @@ class _ChangePasswordDialogState extends State<_ChangePasswordDialog> { setState(() => _error = t.changePasswordErrorCurrentRequired); return; } - if (_ctrl1.text.length < 12) { - setState(() => _error = t.changePasswordErrorMin); - return; + // SEC 2026-08-04 — ce chemin ne contrôlait QUE la longueur. + // + // On pouvait donc créer un coffre avec un mot de passe solide — + // l'écran de création, lui, vérifiait l'entropie — puis le + // remplacer ici par `aaaaaaaaaaaa` : douze caractères, accepté sans + // broncher. La garde protégeait la porte d'entrée pendant que la + // porte de service restait ouverte, et c'est celle-ci qui permet de + // DÉGRADER un coffre existant. + switch (PasswordPolicy.check(_ctrl1.text)) { + case PasswordRejection.tooShort: + setState(() => _error = t.changePasswordErrorMin); + return; + case PasswordRejection.tooWeak: + setState(() => _error = t.passwordTooWeak); + return; + case null: + break; } if (_ctrl1.text != _ctrl2.text) { setState(() => _error = t.changePasswordErrorMismatch); diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 99882b4..80877ca 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; import '../services/heritage_service.dart'; +import '../services/password_policy.dart'; import '../services/password_strength_service.dart'; import '../services/vault_service.dart'; import '../widgets/password_text_field.dart'; @@ -40,13 +41,19 @@ class _SetupScreenState extends State { final t = AppLocalizations.of(context); final p1 = _pass1.text; final p2 = _pass2.text; - if (p1.length < 12) { - setState(() => _error = t.setupErrorMin); - return; - } - if (PasswordStrengthService.score(p1) < 0.6) { - setState(() => _error = t.setupErrorWeak); - return; + // SEC 2026-08-04 — règle unique, partagée avec les quatre autres points + // d'entrée (changement de mot de passe maître, phrase `.ptbak`, coffre + // leurre, mot de passe héritier). Auparavant, ce contrôle d'entropie + // n'existait QUE sur cet écran-ci. + switch (PasswordPolicy.check(p1)) { + case PasswordRejection.tooShort: + setState(() => _error = t.setupErrorMin); + return; + case PasswordRejection.tooWeak: + setState(() => _error = t.passwordTooWeak); + return; + case null: + break; } if (p1 != p2) { setState(() => _error = t.setupErrorMismatch); diff --git a/lib/services/heritage_service.dart b/lib/services/heritage_service.dart index d2b6979..2e2fedb 100644 --- a/lib/services/heritage_service.dart +++ b/lib/services/heritage_service.dart @@ -12,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/entry.dart'; import 'aead_service.dart'; import 'kdf_service.dart'; +import 'password_policy.dart'; import 'monotonic_clock.dart'; import 'vault_service.dart'; @@ -195,8 +196,16 @@ class HeritageService { // COMPLÈTE du coffre dérivée du SEUL heir_password (pas de liaison TEE, // l'héritier déchiffre ailleurs) : un mot de passe faible = brute-force // offline du `pt_heir.enc` depuis une simple copie de fichier. - if (heirPassword.length < 12) { - throw ArgumentError('Heir password : 12 caractères minimum'); + // SEC 2026-08-04 — la règle passe par `PasswordPolicy`, partagée avec les + // écrans. Elle ajoute au contrôle de longueur le rejet des répétitions, + // des suites et des mots de passe courants : `aaaaaaaaaaaa` était accepté. + // Enjeu particulier ici — `pt_heir.enc` n'est PAS lié au matériel, il se + // déchiffre ailleurs à partir de ce seul mot de passe. + if (PasswordPolicy.check(heirPassword) != null) { + throw ArgumentError( + 'Heir password : ${PasswordPolicy.minLength} caractères minimum, ' + 'et pas de suite ni de répétition triviale', + ); } final entries = VaultService().entries; if (entries.isEmpty) { diff --git a/lib/services/password_policy.dart b/lib/services/password_policy.dart new file mode 100644 index 0000000..a2165b0 --- /dev/null +++ b/lib/services/password_policy.dart @@ -0,0 +1,107 @@ +import 'password_strength_service.dart'; + +/// Motif de refus d'un mot de passe. `null` = accepté. +enum PasswordRejection { + /// Trop court (voir [PasswordPolicy.minLength]). + tooShort, + + /// Assez long, mais devinable : répétition (`aaaaaaaaaa`), suite + /// (`abcdefghij`), ou mot de passe figurant dans la liste des plus courants. + tooWeak, +} + +/// Règle UNIQUE de validation des mots de passe et phrases secrètes. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// SEC 2026-08-04 — créée pour supprimer une asymétrie de garde. +/// +/// Le contrôle d'entropie n'existait QUE dans `setup_screen.dart`, à la +/// création du coffre. Les quatre autres points d'entrée — changement du mot de +/// passe maître, phrase secrète d'une sauvegarde `.ptbak`, mot de passe du +/// coffre leurre, mot de passe héritier — ne vérifiaient que la LONGUEUR. +/// +/// Conséquence : on pouvait créer un coffre avec un mot de passe solide, puis +/// le remplacer par `aaaaaaaaaaaa`. Douze caractères, accepté sans broncher, ni +/// répétition détectée ni mot de passe courant rejeté. La porte d'entrée était +/// gardée, aucune des autres portes de la même maison ne l'était. +/// +/// Le cas le plus grave était la phrase du `.ptbak` : c'est le seul artefact +/// NON lié au matériel — restaurable sur n'importe quel appareil, donc +/// attaquable hors ligne — et c'est lui qui avait la garde la plus faible. +/// +/// ───────────────────────────────────────────────────────────────────────── +/// CE QUI N'EST PAS EXIGÉ, ET POURQUOI +/// +/// Aucune obligation de symbole, de chiffre ni de casse. Le NIST déconseille +/// explicitement ces règles de composition depuis SP 800-63B (2017) : elles +/// poussent vers `Motdepasse2024!`, prévisible, et n'ajoutent presque rien. +/// +/// Ce qui est exigé, c'est de l'ENTROPIE — donc de la longueur, ou de la +/// variété, au choix de l'utilisateur. À 12 caractères, les minuscules SEULES +/// suffisent déjà (56 bits) : personne n'est contraint d'ajouter quoi que ce +/// soit. C'est à l'utilisateur de décider comment il atteint la barre, pas à +/// l'application de lui imposer une recette. +/// +/// ⚠️ Le calcul d'entropie SURESTIME les mots de passe humains : `motdepasse` +/// et ses variantes affichent des bits qu'une attaque par dictionnaire français +/// balaie en quelques minutes. `isCommon` ne rattrape que les plus connus. +/// C'est la raison pour laquelle la longueur minimale ne descend pas plus bas, +/// et pourquoi le générateur de phrases de passe reste la meilleure réponse. +abstract final class PasswordPolicy { + PasswordPolicy._(); + + /// Longueur minimale, tous points d'entrée confondus. + /// + /// Maintenue à 12 (décision du 2026-08-04, après avoir envisagé 10). À 12 + /// caractères, même en minuscules seules, on atteint 56 bits — au-dessus du + /// seuil. Aucune classe de caractères n'est donc nécessaire, ce qui est + /// précisément l'objectif : la barre est atteignable par la seule longueur. + /// + /// Une phrase comme `chien bleu matin` passe sans effort. C'est ce que le + /// générateur de phrases de passe produit, et c'est la voie à privilégier. + static const int minLength = 12; + + /// Score minimal (voir [PasswordStrengthService.score]) : 0,6 = 48 bits. + /// + /// Ce seuil pénalise déjà les répétitions, les suites arithmétiques et les + /// mots de passe courants — il fait le travail qu'une règle de composition + /// prétend faire, sans en avoir les défauts. + static const double minScore = 0.6; + + /// Nombre minimal de caractères DISTINCTS. + /// + /// Comble une limite du calcul d'entropie partagé, découverte en écrivant les + /// tests de cette règle : `entropyBits` évalue le pool à partir des CLASSES + /// présentes (26 pour les minuscules), pas des caractères réellement + /// employés. `abababababab` était donc crédité de 56 bits alors qu'il n'en + /// vaut qu'une douzaine — deux lettres alternées. Il franchissait le seuil. + /// + /// ⚠️ Le contrôle est posé ICI et non dans [PasswordStrengthService] : cette + /// fonction de score alimente aussi l'indicateur de force et l'écran d'audit + /// du coffre. En modifier le barème changerait rétroactivement le verdict + /// rendu sur les entrées déjà enregistrées de l'utilisateur — un effet de + /// bord sans rapport avec le sujet. La règle de création se durcit ; la + /// mesure affichée reste ce qu'elle était. + /// + /// Cinq suffit à écarter les alternances et les alphabets minuscules sans + /// gêner personne : la plus courte phrase acceptable de nos tests en compte + /// déjà dix. + static const int minDistinctChars = 5; + + /// Rend `null` si [pwd] est acceptable, sinon le motif du refus. + /// + /// L'ordre compte : on signale la longueur AVANT la faiblesse. Un mot de + /// passe trop court est presque toujours aussi trop faible, et « il manque + /// des caractères » est une consigne actionnable, là où « trop faible » + /// laisse l'utilisateur deviner ce qu'on attend de lui. + static PasswordRejection? check(String pwd) { + if (pwd.length < minLength) return PasswordRejection.tooShort; + if (pwd.split('').toSet().length < minDistinctChars) { + return PasswordRejection.tooWeak; + } + if (PasswordStrengthService.score(pwd) < minScore) { + return PasswordRejection.tooWeak; + } + return null; + } +} diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 42e1251..3165368 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -41,6 +41,7 @@ import 'clipboard_service.dart'; import 'kdf_service.dart'; import 'keystore_service.dart'; import 'monotonic_clock.dart'; +import 'password_policy.dart'; part 'vault_brute_force.dart'; part 'vault_crypto.dart'; @@ -379,6 +380,23 @@ class VaultService { /// et l'unlock retournerait toujours le même (le primary qui est testé /// avant). L'appelant doit valider en amont que le 2 mots de passe diffèrent. Future setupDecoyVault(String decoyPassword) async { + // SEC 2026-08-04 — garde de robustesse au niveau du SERVICE. + // + // `HeritageService.setupOrUpdateSnapshot` en avait une depuis toujours ; + // ce jumeau-ci n'en avait aucune. L'écran est aujourd'hui le seul rempart, + // et un rempart d'interface ne protège que l'interface : tout futur + // appelant — écran refait, test, automatisation — pourrait poser un mot de + // passe leurre trivial sans que rien ne l'arrête. + // + // Un leurre à mot de passe devinable est pire qu'un leurre absent : il + // s'ouvre tout seul sous la contrainte et désigne du même coup l'existence + // du coffre principal. + if (PasswordPolicy.check(decoyPassword) != null) { + throw ArgumentError( + 'Decoy password : ${PasswordPolicy.minLength} caractères minimum, ' + 'et pas de suite ni de répétition triviale', + ); + } // `_createSlot(decoy)` écrase le fichier `_b` (leurre factice) par un VRAI // coffre leurre (contenu + mot de passe choisis par l'utilisateur). await _createSlot(_Slot.decoy, decoyPassword); diff --git a/test/password_policy_test.dart b/test/password_policy_test.dart new file mode 100644 index 0000000..e928a08 --- /dev/null +++ b/test/password_policy_test.dart @@ -0,0 +1,112 @@ +// SEC 2026-08-04 — la règle de mot de passe est désormais UNIQUE et partagée +// par les cinq points d'entrée (création du coffre, changement du mot de passe +// maître, phrase secrète `.ptbak`, coffre leurre, mot de passe héritier). +// +// Auparavant, seul l'écran de création vérifiait l'entropie. Les quatre autres +// ne regardaient que la longueur : `aaaaaaaaaaaa` y passait, y compris sur le +// chemin qui REMPLACE le mot de passe maître d'un coffre existant. +// +// Ces tests couvrent les deux directions, et la seconde compte autant que la +// première : +// • le durcissement rejette bien ce qu'il doit rejeter ; +// • ⚠️ il n'enferme PAS l'utilisateur légitime. Une règle trop stricte qui +// refuse une phrase de passe honnête est un défaut, pas une protection — +// elle pousse vers des mots de passe notés puis vers l'abandon. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pass_tech/services/password_policy.dart'; +import 'package:pass_tech/services/password_strength_service.dart'; + +void main() { + group('PasswordPolicy — ce qui DOIT être accepté', () { + // Le vrai risque de ce correctif : bloquer quelqu'un de légitime. + const acceptables = [ + // Phrase de passe en minuscules seules : à 12 caractères, on atteint + // déjà 56 bits. C'est tout l'intérêt de ne pas exiger de symboles. + 'chienbleumatin', + 'renardclochesoleil', + // Ce que produit le générateur intégré (diceware français). + 'renard-cloche-violet-soleil-7', + // Mélange classique. + 'MonCoffreFort2026', + // Avec espaces — une vraie phrase. + 'le chat dort sur le toit', + // Pile à la longueur minimale, minuscules seules. + 'abricotmiel12', + 'motdepassequejaichoisi', + ]; + + for (final pwd in acceptables) { + test('accepté : "$pwd" (${pwd.length} car.)', () { + expect( + PasswordPolicy.check(pwd), + isNull, + reason: + 'entropie = ${PasswordStrengthService.entropyBits(pwd).toStringAsFixed(1)} bits ; ' + 'un refus ici enfermerait un utilisateur légitime', + ); + }); + } + }); + + group('PasswordPolicy — ce qui DOIT être refusé', () { + test('trop court', () { + expect(PasswordPolicy.check('court'), PasswordRejection.tooShort); + expect( + PasswordPolicy.check('a' * (PasswordPolicy.minLength - 1)), + PasswordRejection.tooShort, + ); + }); + + test('assez long mais répétitif — le cas qui passait partout sauf à la ' + 'création', () { + expect(PasswordPolicy.check('aaaaaaaaaaaa'), PasswordRejection.tooWeak); + expect(PasswordPolicy.check('abababababab'), PasswordRejection.tooWeak); + }); + + test('suite triviale', () { + expect(PasswordPolicy.check('abcdefghijkl'), PasswordRejection.tooWeak); + expect( + PasswordPolicy.check('123456789012345'), + PasswordRejection.tooWeak, + ); + }); + + test('mot de passe courant, même habillé', () { + expect(PasswordPolicy.check('motdepasse123'), PasswordRejection.tooWeak); + expect(PasswordPolicy.check('Password1234!'), PasswordRejection.tooWeak); + }); + + test('vide', () { + expect(PasswordPolicy.check(''), PasswordRejection.tooShort); + }); + }); + + group('PasswordPolicy — invariants de la règle', () { + test('aucune classe de caractères n\'est exigée', () { + // Le NIST déconseille les règles de composition depuis SP 800-63B. + // Ce test échouerait si quelqu'un réintroduisait « il faut un chiffre ». + expect( + PasswordPolicy.check('chienbleumatin'), + isNull, + reason: 'minuscules seules doit suffire dès lors que la longueur y est', + ); + expect( + PasswordPolicy.check('CHIENBLEUMATIN'), + isNull, + reason: 'majuscules seules aussi', + ); + }); + + test('la longueur minimale seule ne suffit pas à faire passer n\'importe ' + 'quoi', () { + final repetitif = 'z' * PasswordPolicy.minLength; + expect( + repetitif.length, + PasswordPolicy.minLength, + reason: 'le test perdrait son sens si la longueur ne collait pas', + ); + expect(PasswordPolicy.check(repetitif), PasswordRejection.tooWeak); + }); + }); +} From d15808c9e19dd5154b3097dde348fbf00e727d9a Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 11:46:57 +0200 Subject: [PATCH 14/34] fix(ux): annuler l'invite biometrique n'est plus presente comme un echec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale par Patrice en usage reel : au lancement, annuler l'invite biometrique affichait « Echec biometrique ». Le service savait DEJA faire la difference. Le `switch` sur `AuthExceptionCode` existe depuis la v2.4.2 et son commentaire promet un « repli silencieux » pour `userCanceled`, `canceled` et `timeout`. Mais les trois cas rendaient `UnlockResult.wrongPassword` — la meme valeur qu'un echec reel. L'information etait calculee, puis jetee avant d'atteindre l'interface. Consequence : quelqu'un qui choisit de taper son mot de passe plutot que de poser son doigt lisait un message d'erreur sur l'ecran le plus sensible de l'application, et pouvait croire son empreinte defaillante. `UnlockResult.biometricCanceled` conserve la distinction jusqu'a l'ecran, qui rend simplement le champ de saisie, sans message. C'est ce que le commentaire de la v2.4.2 decrivait. Cinquieme occurrence du meme motif en deux jours : le mecanisme est correct, c'est ce qu'il TRANSMET qui ne l'est pas. Aucun audit statique n'a signale aucune des cinq — toutes viennent des tests sur appareil. flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/unlock_screen.dart | 27 +++++++++++++++++++++++++++ lib/services/vault_service.dart | 21 +++++++++++++++++++++ lib/services/vault_unlock.dart | 8 +++++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/lib/screens/unlock_screen.dart b/lib/screens/unlock_screen.dart index 3356d14..dac19b1 100644 --- a/lib/screens/unlock_screen.dart +++ b/lib/screens/unlock_screen.dart @@ -369,6 +369,22 @@ class UnlockScreenState extends State { _error = t.vaultBusyRetry; }); break; + case UnlockResult.biometricCanceled: + // UX 2026-08-04 — repli SILENCIEUX, sans message. + // + // L'utilisateur a annulé l'invite, ou l'OS l'a fermée. Rien n'a échoué, + // il n'y a donc rien à signaler : on lui rend simplement le champ de + // saisie, qui est exactement ce qu'il demandait. Auparavant il lisait + // « Échec biométrique » et pouvait croire son empreinte défaillante. + // + // `_error` est remis à `null` et non laissé tel quel : un message issu + // d'une tentative précédente resterait affiché sans rapport. + if (!mounted) return; + setState(() { + _loading = false; + _error = null; + }); + break; } } @@ -460,6 +476,17 @@ class UnlockScreenState extends State { _error = t.vaultBusyRetry; }); break; + case UnlockResult.biometricCanceled: + // Inatteignable depuis l'ouverture par mot de passe — ce chemin ne + // sollicite jamais l'invite biométrique. Présent pour l'exhaustivité + // de l'énumération, et traité comme un non-événement plutôt que comme + // une erreur, au cas où un refactor futur le ferait remonter ici. + if (!mounted) return; + setState(() { + _loading = false; + _error = null; + }); + break; } } diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 3165368..c03ae39 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -132,6 +132,27 @@ enum UnlockResult { /// /// ⚠️ Ne déclenche AUCUN comptage d'échec : rien n'a été tenté. busy, + + /// UX 2026-08-04 — l'invite biométrique a été ANNULÉE, pas échouée. + /// + /// Couvre `userCanceled` (l'utilisateur a tapé Annuler ou Retour), + /// `canceled` (l'OS a fermé l'invite : bascule d'application, écran + /// verrouillé) et `timeout` (l'invite a expiré sans interaction). + /// + /// Ces trois cas rendaient [wrongPassword], et l'écran affichait donc + /// « Échec biométrique » — alors que rien n'a échoué. Annuler est une + /// DÉCISION : « je préfère taper mon mot de passe ». Lui répondre par un + /// message d'erreur, sur l'écran le plus sensible de l'application, laisse + /// croire à un dysfonctionnement de l'empreinte. + /// + /// Le service savait déjà faire la différence — le `switch` sur + /// `AuthExceptionCode` existe depuis la v2.4.2, et son commentaire promettait + /// un « repli silencieux ». Mais l'information calculée était jetée en + /// rendant la même valeur qu'un échec réel. Ce cas la conserve jusqu'à + /// l'interface. + /// + /// ⚠️ Aucun comptage d'échec : aucune tentative de déchiffrement n'a eu lieu. + biometricCanceled, } /// Identifie quel slot du vault est en cours d'utilisation. diff --git a/lib/services/vault_unlock.dart b/lib/services/vault_unlock.dart index 872c15d..f63abdd 100644 --- a/lib/services/vault_unlock.dart +++ b/lib/services/vault_unlock.dart @@ -665,7 +665,13 @@ extension VaultUnlock on VaultService { case AuthExceptionCode.userCanceled: case AuthExceptionCode.canceled: case AuthExceptionCode.timeout: - return UnlockResult.wrongPassword; + // UX 2026-08-04 — rend `biometricCanceled` et non plus + // `wrongPassword`. Le « repli silencieux » que ce commentaire + // promettait depuis la v2.4.2 n'existait pas : la distinction était + // calculée ici, puis perdue en rendant la valeur d'un vrai échec. + // L'écran affichait « Échec biométrique » à quelqu'un qui venait + // simplement de choisir de taper son mot de passe. + return UnlockResult.biometricCanceled; default: // Cleanup best-effort — la clé Keystore est probablement morte, // tenter de la réutiliser sur la prochaine tentative donnerait From 94827aa9519b4ae21f791e5ec736a16aeeba75f8 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 11:53:00 +0200 Subject: [PATCH 15/34] fix(ux): bouton Deverrouiller desactive quand le champ est vide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale en usage reel : appuyer sur « Deverrouiller » sans rien avoir saisi ne produisait rien du tout. `_unlock()` sort en silence sur `pass.isEmpty`, donc l'appui etait avale — aucun message, aucun indice. Sur l'ecran d'entree de l'application, un bouton qui n'a aucune reaction laisse penser qu'elle est figee. Bouton grise plutot que message d'erreur : l'ecran affiche DEJA « Entrez votre mot de passe maitre » juste au-dessus du champ. Un message le repeterait a trois centimetres, et sous forme de reproche apres l'appui. Le bouton desactive dit la meme chose AVANT, selon la convention Material. `ValueListenableBuilder` sur le controleur plutot qu'un `onChanged` avec `setState` : `TextEditingController` EST deja un `ValueNotifier`, et seul le bouton se reconstruit au lieu de tout l'ecran a chaque frappe. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/unlock_screen.dart | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/lib/screens/unlock_screen.dart b/lib/screens/unlock_screen.dart index dac19b1..a36c1d6 100644 --- a/lib/screens/unlock_screen.dart +++ b/lib/screens/unlock_screen.dart @@ -687,12 +687,33 @@ class UnlockScreenState extends State { else Column( children: [ - FilledButton.icon( - onPressed: _unlock, - icon: const Icon(Icons.lock_open, size: 18), - label: Text(t.unlockCta), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), + // UX 2026-08-04 — le bouton est DÉSACTIVÉ tant que le + // champ est vide. + // + // Signalé en usage réel : appuyer sur « Déverrouiller » + // sans rien avoir saisi ne produisait rien du tout. + // `_unlock()` sort en silence sur `pass.isEmpty`, donc + // l'appui était avalé — aucun message, aucun indice. + // Sur l'écran d'entrée de l'application, un bouton qui + // ne réagit pas laisse penser qu'elle est figée. + // + // Un bouton grisé vaut mieux qu'un message d'erreur : + // il dit AVANT l'appui qu'il n'y a rien à valider, + // au lieu de le reprocher après. + // + // `ValueListenableBuilder` plutôt qu'un `onChanged` qui + // appellerait `setState` : le contrôleur EST déjà un + // `ValueNotifier`, et seul le bouton se reconstruit — + // pas tout l'écran à chaque frappe. + ValueListenableBuilder( + valueListenable: _passCtrl, + builder: (_, value, _) => FilledButton.icon( + onPressed: value.text.isEmpty ? null : _unlock, + icon: const Icon(Icons.lock_open, size: 18), + label: Text(t.unlockCta), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), ), ), if (_hasBiometric) ...[ From 39089a92216e4c78e97fabdcf50fb19359e91b95 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 12:22:31 +0200 Subject: [PATCH 16/34] sec: sauvegarde corrompue au partage + 6e tampon de mot de passe non efface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit Gemini n3 (modele gemini-3.6-flash — les modeles pro renvoyaient des HTTP 503 en rafale). 6 findings, 3 confirmees apres relecture du code. PT-001 — EXPORT POTENTIELLEMENT CORROMPU (le plus important) `Share.shareXFiles` rend la main des que l'activite cible se TERMINE, ce qui ne signifie pas qu'elle a fini de LIRE. Une cible qui televerse en tache de fond — Drive, messagerie — lit encore apres notre retour. Or le `finally` dechiquetait aussitot la copie que `share_plus` place dans son cache, c'est-a- dire la source d'octets que la cible etait en train de lire. Resultat possible : une sauvegarde `.ptbak` corrompue, decouverte le jour de la restauration. Le raisonnement de SEC F8 v2.5.2 reste valable — cette copie en clair ne doit pas survivre indefiniment — mais purger a CET instant precis creait le defaut inverse. La purge est donc deplacee vers l'export SUIVANT, ou elle balaie les residus du precedent (appel deja en place avant le partage depuis le 2026-08-03). Notre propre fichier temporaire, lui, continue d'etre dechiquete immediatement : ce n'est pas celui que la cible lit. Residu borne dans le temps, sans jamais couper une lecture en cours. PT-MEM-003 — 6e tampon UTF-8 de mot de passe non efface `utf8.encode(password)` dans le controle de fuite HIBP. Meme motif que les cinq corriges le 2026-08-03 — manque a l'appel. Chemin sensible : l'utilisateur y soumet volontairement ses mots de passe, un par un. ECARTE APRES VERIFICATION PT-BRUTE-001 « la panique reinitialise le verrouillage anti-force-brute, d'ou force brute illimitee » : FAUX. `panic()` n'a qu'UN appelant, `settings_screen.dart:636`, donc derriere le deverrouillage. Ni tuile de reglages rapides, ni intent externe, ni code de panique a l'ecran de deverrouillage — la tuile et l'intent que la finding suppose n'existent pas. Qui atteint les Reglages a deja ouvert le coffre. La purge est par ailleurs deliberee (F15 v2.4.4) : un verrouillage survivant signalerait qu'une urgence vient d'avoir lieu. PT-002 : deja corrige le 2026-08-03 (purge en debut d'export). RESTE OUVERT, NON CORRIGE PT-MEM-001/002 : `encrypter.decrypt()` rend une `String` Dart, immuable donc ineffacable, sur les chemins HERITES v1/v2 (instantane heritier v1, `.ptbak` v1/v2). Reel. Corrigeable via `decryptBytes`, mais cela touche des chemins de dechiffrement herites qu'aucun test ne couvre et que l'appareil ne peut plus exercer — a traiter separement, pas en fin de lot. flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/settings_screen.dart | 22 ++++++++++++++++++++-- lib/services/breach_service.dart | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d8701c1..f2ceeca 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -850,7 +850,21 @@ class _SettingsScreenState extends State // indéfiniment à côté — sans mot de passe, sans Keystore, sans Argon2id // pour le protéger. Le plugin accorde en outre une permission de lecture // sur cette copie à toute activité résolvant le sélecteur. - _shredStaleExports(dir); + // + // SEC 2026-08-04 — cette purge est DÉPLACÉE vers l'export suivant. + // + // `Share.shareXFiles` rend la main dès que l'activité cible se termine, + // ce qui ne signifie pas qu'elle a fini de LIRE. Une cible qui téléverse + // en tâche de fond — Drive, messagerie — lit encore après notre retour. + // Écraser sa source d'octets aléatoires à cet instant produirait un + // export CORROMPU, que l'on ne découvrirait qu'au moment d'en avoir + // besoin. C'est le pire moment possible pour une sauvegarde. + // + // Le raisonnement de SEC F8 reste entièrement valable : cette copie ne + // doit pas survivre indéfiniment. Elle est donc purgée par l'appel à + // `_shredStaleExports` placé AVANT le partage, qui balaie les résidus de + // l'export précédent. Le résidu est borné dans le temps sans jamais + // couper une lecture en cours. } } @@ -971,8 +985,12 @@ class _SettingsScreenState extends State XFile(file.path, mimeType: 'application/octet-stream'), ], subject: t.exportEncryptedShareSubject); } finally { + // SEC 2026-08-04 — voir `_exportVault` : on déchiquette NOTRE fichier, + // jamais la copie de `share_plus` que la cible est peut-être encore en + // train de lire. Une sauvegarde `.ptbak` corrompue au moment du + // téléversement serait découverte le jour de la restauration, quand il + // est trop tard. La copie du plugin est purgée à l'export suivant. _shredFile(file); - _shredStaleExports(dir); } } catch (e) { if (!mounted) return; diff --git a/lib/services/breach_service.dart b/lib/services/breach_service.dart index 4cd8c30..ce2a23f 100644 --- a/lib/services/breach_service.dart +++ b/lib/services/breach_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; +import 'package:files_tech_core/files_tech_core.dart'; import 'package:http/http.dart' as http; /// Privacy-preserving breach check via HaveIBeenPwned k-anonymity API. @@ -27,7 +28,21 @@ class BreachService { static Future checkPassword(String password) async { if (password.isEmpty) return 0; try { - final hash = sha1.convert(utf8.encode(password)).toString().toUpperCase(); + // SEC 2026-08-04 — le tampon UTF-8 du mot de passe est effacé après le + // calcul de l'empreinte. + // + // Sixième site du même motif, manqué lors du passage du 2026-08-03 qui + // en avait corrigé cinq (KDF, export, héritage ×2, import). Celui-ci est + // dans le contrôle de fuite HIBP, c'est-à-dire sur un chemin où + // l'utilisateur soumet volontairement ses mots de passe les plus + // sensibles, un par un. + final pwBytes = utf8.encode(password); + final String hash; + try { + hash = sha1.convert(pwBytes).toString().toUpperCase(); + } finally { + SecretBytes.wipe(pwBytes); + } final prefix = hash.substring(0, 5); final suffix = hash.substring(5); From e8904d150b5a24bba5c375ad9e3f54d3ce277060 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 12:39:28 +0200 Subject: [PATCH 17/34] sec: les 3 dernieres findings ouvertes sont traitees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. STRING INEFFACABLE SUR LES CHEMINS HERITES (Gemini n3 PT-MEM-001/002) `encrypter.decrypt()` rend une `String` Dart : immuable, donc impossible a ecraser. Le coffre entier en clair restait en memoire jusqu'au ramasse-miettes sur deux chemins herites — instantane heritier v1 et `.ptbak` v1/v2. Remplace par `decryptBytes` + effacement en `finally`. ⚠️ `allowMalformed: true` est REPRIS explicitement : c'est exactement ce que fait `Encrypter.decrypt` en interne (encrypt 5.0.3, `encrypter.dart:49`, verifie dans la source du paquet avant d'ecrire la moindre ligne). L'omettre aurait fait lever une exception la ou l'ancien code tolerait un octet malforme — une regression silencieuse sur des fichiers herites que plus aucun test ni appareil n'exerce. 2. FLAG_SECURE REARME DE FACON ASYNCHRONE (Gemini n2 PT-002) `suspendRelaxForBackground()` fait ce travail depuis SEC F3 v2.5.2, mais depuis Dart, a travers un canal de methode — donc APRES le retour de `onPause`. Or Android capture la vignette des applications recentes au moment du passage en arriere-plan. Une note ouverte pouvait donc s'y retrouver lisible. `MainActivity.onPause()` pose desormais le flag lui-meme, sur le thread UI, dans le cycle de vie natif, avant la capture. Plus de course. ⚠️ Ce n'est PAS un retour a la v2.3.8. Le piege Knox documente en v2.3.9 vient de FLAG_SECURE pose a la CREATION de la fenetre, qui rend tout `clearFlags` ulterieur sans effet sur le presse-papier. Ici la fenetre existe depuis longtemps — la distinction est ce qui rend ce correctif possible. Il fallait deux informations distinctes cote natif : le relachement de l'editeur de notes passe aussi par `setSecure(false)`, et c'est justement dans cet etat qu'il faut securiser. D'ou `setSecureOnBackground`, qui transmet la PREFERENCE globale et non l'etat courant du flag. Se fier au dernier `setSecure` aurait laisse la note exposee — le defaut meme a corriger. La restauration reste a Dart (`resumeRelaxAfterBackground`) : au retour, mieux vaut un bref instant de trop securise qu'un instant de trop expose. 3. CAMOUFLAGE PARTIEL — non corrigeable `android:label` n'est pas modifiable a l'execution : Reglages > Applications affichera toujours « Pass Tech » en mode panique. Il faudrait un second APK avec un autre applicationId. Documente dans THREAT_MODEL.md §4. flutter analyze : 0 issue. 153 tests verts. Kotlin compile. A TESTER SUR APPAREIL — c'est la seule verification possible : - note en edition, bascule en arriere-plan, recents : vignette VIDE - la note doit rester copiable/collable au retour (non-regression Knox) Co-Authored-By: Claude Opus 5 (1M context) --- .../com/passtech/pass_tech/MainActivity.kt | 59 +++++++++++++++++++ lib/services/heritage_service.dart | 48 +++++++++++++-- lib/services/import_export_service.dart | 24 +++++++- lib/services/secure_window.dart | 26 ++++++++ 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt b/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt index ebca12c..d709719 100644 --- a/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt +++ b/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt @@ -46,6 +46,51 @@ class MainActivity : FlutterFragmentActivity() { // (relax/restore depuis n'importe quel écran). } + /// SEC 2026-08-04 — faut-il forcer FLAG_SECURE au passage en arrière-plan ? + /// + /// Reflète UNIQUEMENT la préférence globale de l'utilisateur + /// (`SecureWindow._userDisabled` côté Dart), jamais le relâchement + /// transitoire de l'éditeur de notes. C'est toute la subtilité : une note + /// en cours d'édition a volontairement FLAG_SECURE relâché, et c'est + /// précisément à ce moment-là qu'il faut le remettre avant l'instantané. + private var secureOnBackground = true + + /// SEC 2026-08-04 — réarme FLAG_SECURE de façon SYNCHRONE avant + /// l'instantané système. + /// + /// `SecureWindow.suspendRelaxForBackground()` fait déjà ce travail depuis + /// SEC F3 v2.5.2, mais depuis Dart, à travers un canal de méthode — donc + /// APRÈS le retour de `onPause`. Android, lui, capture sa vignette pour le + /// sélecteur d'applications récentes au moment du passage en arrière-plan. + /// Le flag arrivait donc potentiellement après la photo : une note ouverte + /// pouvait se retrouver lisible dans les récentes. + /// + /// Ici on est sur le thread UI, dans le cycle de vie natif, avant que la + /// vignette soit prise. Il n'y a plus de course. + /// + /// ⚠️ Ne PAS déplacer cet appel vers `onCreate` : c'est exactement ce que + /// la v2.3.9 a retiré. Sur One UI + Knox, FLAG_SECURE posé à la CRÉATION + /// de la fenêtre rend tout `clearFlags` ultérieur sans effet sur le + /// presse-papier et la barre de sélection. Ici la fenêtre existe déjà + /// depuis longtemps, le piège ne s'applique pas. + /// + /// La restauration reste à la charge de Dart + /// (`resumeRelaxAfterBackground`) : au retour, mieux vaut un bref instant + /// de trop sécurisé qu'un instant de trop exposé. + override fun onPause() { + if (secureOnBackground) { + try { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } catch (_: Throwable) { + // best-effort : ne jamais empêcher la mise en arrière-plan + } + } + super.onPause() + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -79,6 +124,20 @@ class MainActivity : FlutterFragmentActivity() { } } } + // SEC 2026-08-04 — la préférence GLOBALE de l'utilisateur, + // transmise une fois pour toutes plutôt que déduite du + // dernier `setSecure`. + // + // Il fallait bien deux informations distinctes : le + // relâchement de l'éditeur de notes passe aussi par + // `setSecure(false)`, et c'est justement dans cet état + // qu'il faut sécuriser au passage en arrière-plan. Se fier + // au dernier `setSecure` aurait laissé la note exposée — + // exactement le défaut à corriger. + "setSecureOnBackground" -> { + secureOnBackground = call.argument("enabled") ?: true + result.success(null) + } else -> result.notImplemented() } } diff --git a/lib/services/heritage_service.dart b/lib/services/heritage_service.dart index 2e2fedb..7260ed0 100644 --- a/lib/services/heritage_service.dart +++ b/lib/services/heritage_service.dart @@ -383,6 +383,23 @@ class HeritageService { } } + /// Écrase une `List` en place. + /// + /// `Encrypter.decryptBytes` déclare rendre une `List`, pas une + /// `Uint8List` — `SecretBytes.wipe`, qui attend le type concret, ne peut donc + /// pas être appelé directement. On écrase élément par élément, et on avale + /// l'exception si la liste s'avère non modifiable : mieux vaut un effacement + /// qui échoue qu'un déchiffrement qui plante. + static void _wipeIntList(List buf) { + try { + for (var i = 0; i < buf.length; i++) { + buf[i] = 0; + } + } catch (_) { + /* vue non modifiable — le ramasse-miettes reprendra la main */ + } + } + /// AAD bound to a v2 heir snapshot (anti-downgrade). /// /// AUDIT 2026-08-03 — [params] est un argument, et non plus les constantes @@ -547,14 +564,35 @@ class HeritageService { final encKey = enc.Key(encKeyBytes); final iv = enc.IV(Uint8List.fromList(ivBytes)); final encrypter = enc.Encrypter(enc.AES(encKey, mode: enc.AESMode.cbc)); - final plain = encrypter.decrypt( + // SEC 2026-08-04 — `decryptBytes` au lieu de `decrypt`. + // + // `decrypt()` rend une `String` Dart : immuable, donc IMPOSSIBLE à + // écraser. Le coffre entier — tous les identifiants en clair — restait + // en mémoire jusqu'au passage du ramasse-miettes, à une date non + // déterministe. Toute la stratégie d'effacement de l'application était + // contournée sur ce chemin. + // + // ⚠️ `allowMalformed: true` est OBLIGATOIRE : c'est exactement ce que + // fait `Encrypter.decrypt` en interne (encrypt 5.0.3, + // `encrypter.dart:49`). Sans lui, un octet malformé lèverait une + // exception là où l'ancien code produisait un caractère de remplacement + // — un changement de comportement silencieux sur les instantanés hérités. + final plainBytes = encrypter.decryptBytes( enc.Encrypted(Uint8List.fromList(cipherBytes)), iv: iv, ); - final list = jsonDecode(plain) as List; - return list - .map((e) => Entry.fromJson(e as Map)) - .toList(); + try { + final plain = utf8.decode(plainBytes, allowMalformed: true); + final list = jsonDecode(plain) as List; + return list + .map((e) => Entry.fromJson(e as Map)) + .toList(); + } finally { + // En `finally` et non après le décodage : un instantané corrompu fait + // lever `jsonDecode`, et c'est justement le cas où le tampon en clair + // resterait intact en mémoire. + _wipeIntList(plainBytes); + } } catch (_) { return null; } finally { diff --git a/lib/services/import_export_service.dart b/lib/services/import_export_service.dart index 0b71777..c7c270f 100644 --- a/lib/services/import_export_service.dart +++ b/lib/services/import_export_service.dart @@ -596,10 +596,32 @@ class ImportExportService { encKeyBytes = key.sublist(0, 32); final encKey = enc.Key(encKeyBytes); final encrypter = enc.Encrypter(enc.AES(encKey, mode: enc.AESMode.cbc)); - final plain = encrypter.decrypt( + // SEC 2026-08-04 — `decryptBytes` au lieu de `decrypt`. + // + // `decrypt()` rend une `String` Dart, immuable donc impossible à + // écraser : la sauvegarde entière en clair restait en mémoire jusqu'au + // ramasse-miettes. Même correctif que dans `HeritageService`. + // + // ⚠️ `allowMalformed: true` reproduit EXACTEMENT ce que fait + // `Encrypter.decrypt` en interne (encrypt 5.0.3, `encrypter.dart:49`). + // L'omettre ferait lever une exception là où l'ancien code tolérait un + // octet malformé — régression silencieuse sur les `.ptbak` hérités. + final plainBytes = encrypter.decryptBytes( enc.Encrypted(Uint8List.fromList(cipher)), iv: enc.IV(Uint8List.fromList(iv)), ); + final String plain; + try { + plain = utf8.decode(plainBytes, allowMalformed: true); + } finally { + try { + for (var i = 0; i < plainBytes.length; i++) { + plainBytes[i] = 0; + } + } catch (_) { + /* vue non modifiable */ + } + } final list = jsonDecode(plain) as List; final entries = []; diff --git a/lib/services/secure_window.dart b/lib/services/secure_window.dart index 50da3c5..33e4c1e 100644 --- a/lib/services/secure_window.dart +++ b/lib/services/secure_window.dart @@ -58,6 +58,19 @@ class SecureWindow { /// déjà posé. static Future applyUserPreference({required bool enabled}) async { _userDisabled = !enabled; + // SEC 2026-08-04 — informe le natif de la préférence GLOBALE, pour qu'il + // puisse réarmer FLAG_SECURE lui-même dans `onPause`, avant qu'Android ne + // prenne la vignette des applications récentes. + // + // `suspendRelaxForBackground()` fait déjà ce travail, mais depuis Dart : + // l'appel de canal ne revient qu'APRÈS le retour de `onPause`, donc + // potentiellement après la photo. Le natif, lui, agit dans le cycle de vie + // lui-même — il n'y a plus de course. + // + // On transmet la préférence et NON l'état courant du flag : l'éditeur de + // notes relâche volontairement FLAG_SECURE, et c'est précisément dans cet + // état qu'il faut sécuriser au passage en arrière-plan. + await _setSecureOnBackground(enabled); if (_userDisabled) { // Retire le flag s'il avait été posé par un init() précédent // (cas où l'utilisateur toggle off à chaud). @@ -94,6 +107,19 @@ class SecureWindow { /// composants → copier/coller restent bloqués. Poser FLAG_SECURE /// APRÈS la création (depuis Dart, post-runApp) évite ce piège tout /// en préservant la protection screenshots / aperçu Recent Apps. + /// Transmet au natif s'il doit réarmer FLAG_SECURE dans `onPause`. + /// Best-effort : un échec ne doit jamais bloquer l'appelant, le repli étant + /// le comportement Dart existant (`suspendRelaxForBackground`). + static Future _setSecureOnBackground(bool enabled) async { + try { + await _channel.invokeMethod('setSecureOnBackground', { + 'enabled': enabled, + }); + } catch (_) { + /* ancienne version native ou plateforme non Android */ + } + } + static Future init() async { if (_initialized) return; if (_userDisabled) return; From cdd65331f609e29d7f4e88ce165fb1fd891d64b5 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 12:44:23 +0200 Subject: [PATCH 18/34] ui: separer visuellement portee, types et categories dans la barre de filtres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale par Patrice : « tu n'as pas ajoute Email, Reseaux sociaux, Banque ? » dans le menu Ajouter. La reponse est non — ce sont des CATEGORIES, pas des TYPES — mais la question revele un vrai defaut d'affichage. La rangee de filtres empile trois familles qui ne se comparent pas : - la portee : Tous, Favoris - le TYPE : Mot de passe, Note, Carte bancaire (decide des champs du formulaire) - la CATEGORIE : Web, Email, Banque, Reseaux sociaux… (simple rangement) Toutes s'affichaient a l'identique. On lisait donc « Cartes bancaires » et « Cartes » comme deux variantes de la meme chose, alors que la premiere filtre un type et la seconde une categorie. Meme l'auteur de l'application n'y retrouvait plus la distinction — un nouvel utilisateur encore moins. Deux traits fins aux frontieres de groupe. Choisi contre les deux alternatives : un en-tete sur une seconde rangee coute ~40 px sur la liste des entrees, soit une entree visible en moins en permanence sur l'ecran le plus consulte ; un libelle dans la rangee repousse les categories vers la droite et allonge le defilement. Le trait ne coute ni hauteur ni appui. Frontieres CALCULEES et non ecrites en dur : ajouter un type ou une categorie les deplace toute seule. flutter analyze : 0 issue. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/home_screen.dart | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 48ac33f..ee258ed 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -494,7 +494,41 @@ class _HomeScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), scrollDirection: Axis.horizontal, itemCount: _chips.length, - separatorBuilder: (_, i) => const SizedBox(width: 6), + // UI 2026-08-04 — séparateurs aux FRONTIÈRES DE GROUPE. + // + // Cette rangée empile trois familles de filtres qui ne se + // comparent pas : la portée (Tous, Favoris), le TYPE d'entrée + // (Mot de passe, Note, Carte bancaire — il décide des champs du + // formulaire) et la CATÉGORIE (Web, Email, Banque… — un simple + // rangement). Toutes s'affichaient à l'identique, si bien qu'on + // lisait « Cartes bancaires » et « Cartes » comme deux variantes + // de la même chose, alors que la première filtre un type et la + // seconde une catégorie. + // + // Un trait fin plutôt que deux rangées ou des en-têtes : la + // rangée reste unique, donc aucun appui supplémentaire et aucune + // hauteur prise sur la liste des entrées, qui est ce que + // l'utilisateur vient voir. + // + // Les frontières sont CALCULÉES et non écrites en dur : ajouter + // un type ou une catégorie les déplace toute seule. + separatorBuilder: (_, i) { + final finPortee = 1; // ...Tous, Favoris | types... + final finTypes = 1 + _typeChips.length; // ...types | catégories + if (i == finPortee || i == finTypes) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.outlineVariant, + ), + ); + } + return const SizedBox(width: 6); + }, itemBuilder: (context, i) { final chip = _chips[i]; final selected = _filter == chip; From 7e043abdf0b3948c68c27070b53eabb7a969bc9f Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 13:25:49 +0200 Subject: [PATCH 19/34] feat(ux): confirmation avant d'abandonner une saisie non enregistree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale par Patrice : creer une note, appuyer sur Retour sans enregistrer, et tout disparait sans un mot. Sur une note longue, la perte est reelle et rien ne la laissait prevoir. `PopScope` est deja l'idiome de la maison (`heir_view_screen`, `splash_screen`) — il n'avait simplement pas ete applique au seul ecran ou l'on peut perdre du travail. Meme motif que le reste de ce chantier : la garde existe, elle n'a pas ete propagee. La boite ne s'affiche QUE si le formulaire a reellement change : une empreinte est relevee a l'ouverture, apres remplissage des controleurs, et comparee au moment du retour. Consulter une entree puis revenir en arriere ne demande rien. « Continuer la saisie » est en position sure et non destructive : c'est l'issue a privilegier quand on a appuye sur Retour par erreur. `_save()` appelle `Navigator.pop` directement, donc l'enregistrement n'est pas intercepte. Seuls le Retour systeme et la fleche de la barre de titre passent par `maybePop`. ECARTE : un dossier BROUILLON, envisage puis rejete avec Patrice. Hors du coffre, il ecrirait des secrets EN CLAIR sur le disque, survivant au verrouillage — la promesse meme de l'application. Dans le coffre, une entree a moitie saisie entrerait dans la liste, dans les sauvegardes `.ptbak` et dans l'instantane d'heritage. Il survivrait en outre au mode panique, qui n'efface que la RAM. Surtout, il creerait un SECOND endroit ou vivent des secrets, avec son propre cycle de vie et ses propres regles d'effacement. Tous les defauts de ce chantier viennent d'une garde posee a un endroit et pas a son jumeau : c'eut ete un jumeau de plus a tenir synchronise, pour toujours. Une breche permanente pour regler un probleme ponctuel. flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/l10n/app_en.arb | 4 ++++ lib/l10n/app_fr.arb | 4 ++++ lib/screens/entry_edit_screen.dart | Bin 29034 -> 32148 bytes 3 files changed, 8 insertions(+) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 46121bd..424c5fc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -325,6 +325,10 @@ "entryEditField2faOptional": "2FA code — TOTP secret (optional)", "entryEditHint2fa": "Paste the otpauth:// URI or the Base32 key", "entryEditHelper2fa": "Generates 6-digit codes. Paste the otpauth:// URI shown below the service's QR code — the secret is extracted automatically.", + "entryEditDiscardTitle": "Discard changes?", + "entryEditDiscardBody": "What you have just typed has not been saved and will be lost.", + "entryEditDiscardConfirm": "Discard", + "entryEditDiscardKeep": "Keep editing", "entryEditFieldNotesOptional": "Notes (optional)", "entryEditHintNotes": "Additional information…", "entryEditFieldContent": "Content", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fbc971c..ea5fa92 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -314,6 +314,10 @@ "entryEditField2faOptional": "Code 2FA — secret TOTP (optionnel)", "entryEditHint2fa": "Collez l'URI otpauth:// ou la clé Base32", "entryEditHelper2fa": "Génère des codes à 6 chiffres. Collez l'URI otpauth:// affichée sous le QR code du service — le secret en est extrait automatiquement.", + "entryEditDiscardTitle": "Abandonner les modifications ?", + "entryEditDiscardBody": "Ce que vous venez de saisir n'a pas été enregistré et sera perdu.", + "entryEditDiscardConfirm": "Abandonner", + "entryEditDiscardKeep": "Continuer la saisie", "entryEditFieldNotesOptional": "Notes (optionnel)", "entryEditHintNotes": "Informations supplémentaires…", "entryEditFieldContent": "Contenu", diff --git a/lib/screens/entry_edit_screen.dart b/lib/screens/entry_edit_screen.dart index b262b1a7dffddcfbbcbfbb05dff4fe5e17028852..7bd2570547cc3091f3f5b92d1fb69a4b90179d62 100644 GIT binary patch delta 2887 zcmZ`*O^6&t6m}09i6|-&g8_N*Z)V755;sO z{k!nfmkaw-nR^!!uZr=7g#$M~U-;wR^4PIsbopIcI==Mc(c>>2J^ljS{`4!7!^}x# zyreWx&$?mILODs3??^XnD5Uej$@YFC?63aP8U}emE()t0<%+Hasqv*`U~@@=CR={8 zg?WX!RAxct?UF7ViM7K_%Q{YCO(L++2(PT6JoI|X(=y>1OOt_g_);cvPL^_2XIJUM z<#R}o420=VwiYRuLCc{uzK)qUl%^3@>?~>WT}hdAX>}AWaNeFWLerXe%Jiu-!{MA! zUI{Hv?MK9VwlDqMinBI2!HAtcLszhfsN+YOY#T9@i`8!&OZKfB)jw7Kx+n|ll%Fo} z%4>PnJKgqj)7Q&FF5T`ioIc+@7M$Ka>g5i9_Axn1u|FLA6jLaje8oc2=BVJnj*N7D)PKFtH#N`0()h|ce^h$ z)DNGY>&3KN-@m`_zLGgg7p0cPWJ}V@+ZQLFt&x`G&|HmrL?0}dIe~ZHO@7ugxEu@; zCI?%rq5#%`Ne;VX8xc)zqL$d~OgJ(5(K`W(4sEJ>ij$<18~IcyVGj~Y~7gHmBw1_NY#o7S+IvE<04 zt?36O(1Hc%r`m@>Q7i`iK|tLIUZF?O4K|yGAg54t5vY(xxt;#gecNVhiOpn}I7~X( z`jG9aY^YS(A@`WG4dFg;l4f@xmFpBrZglblv@%^*0wdndGcEGLbhs4_5%z)f1-H2} zD>j$PgrcbdD6OsqfGR?CADCSNKOkF9eyIb)ISoz=j4IDzZv}G@RPQ{=c{n1IXe-gu zu-RR>ytw)?FGHdhmTOhTU^H<9X5^XbE#y=2@%T0}BH zAjt8ZN0!RUdFKdxn#4u~d|)?TgZbKim9`mUQxq(0610VKXMRbv9u%9|GBp#w*oM&T%Quo9{jN^&wo8_P8hU70D$sV@H!S zhaBa8as!UDdWpN#(~}!4;>v&4Au)cA=piO(gx}3;L9v@a_Ka|MC`RiNe%5WZsSEuS zvTU}x+!3jfsJjE&?lcx(h7up|J&TYpWT+e49vyAMFcYG0%}Pgy`N|hLLWK=FRIRP` zS-WZ}r^-o$!Ps&I&QuSU$Ih9;ek9X(lpkD_>@bjEs#44>QfH?#aBJ8ds_Cg2@SCst z@LZ+-+GE}t&YR40&*7aC0Dju4t!8`M;i$Du-=3|gPO=@G#;2b+^b{@|(`?u4r|Z+> XBm-E;^7x Date: Tue, 4 Aug 2026 13:33:07 +0200 Subject: [PATCH 20/34] ci: fournir GITHUB_TOKEN a claude-code-action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job echouait sur CHAQUE pull request, quel qu'en soit le contenu : App token exchange failed: 401 Unauthorized Claude Code is not installed on this repository Le message ne dit pas que le depot est absent — il l'est bien — mais que l'application GitHub « Claude Code » n'est pas autorisee dessus. La cle API etait presente ; c'est la seconde voie d'authentification qui manquait. Verifie dans les entrees reelles de l'action (anthropics/claude-code-action, action.yml) plutot que suppose : `github_token` y est decrit comme « optional if using GitHub App ». Les deux sont donc des voies ALTERNATIVES, et le workflow n'en fournissait aucune. `GITHUB_TOKEN` porte deja les droits necessaires — contents, pull-requests et issues sont declares dans le bloc `permissions:` du job. Installer l'app reste possible si l'on prefere que les commentaires soient signes par elle plutot que par github-actions[bot]. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-review.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index fc7f0c3..2b15391 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -59,6 +59,25 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # CI 2026-08-04 — le jeton du workflow, au lieu de l'app GitHub. + # + # Sans lui, l'action tente d'obtenir un jeton via l'application + # GitHub « Claude Code », qui n'est pas autorisée sur ce dépôt : + # App token exchange failed: 401 Unauthorized + # Claude Code is not installed on this repository + # Le job échouait donc sur CHAQUE pull request, quel qu'en soit le + # contenu — la clé API était bien présente, c'est l'app qui manquait. + # + # Les entrées de l'action décrivent `github_token` comme + # « optional if using GitHub App » : les deux sont des voies + # ALTERNATIVES, et le workflow n'en fournissait aucune. + # + # `GITHUB_TOKEN` porte déjà les droits nécessaires — ils sont + # déclarés dans le bloc `permissions:` ci-dessus (contents, + # pull-requests, issues). Installer l'app reste possible si l'on veut + # que les commentaires soient signés par elle plutôt que par + # `github-actions[bot]`. + github_token: ${{ secrets.GITHUB_TOKEN }} # Prompt pour les auto-reviews PR (skip pour @claude mentions # qui suivent l'instruction du commentaire). prompt: | From aafc4439fa0b12a41ec8101f67b17e70fbf68d3d Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 13:36:42 +0200 Subject: [PATCH 21/34] ci: syntaxe Exodus + modele fixe, et README bilingue (EN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI — deux echecs distincts sur la PR #14 : 1. `promesses` : `exodus_analyze.py: error: unrecognized arguments`. `-j` est un DRAPEAU (sortie JSON), il ne prend pas de nom de fichier ; c'est `-o` qui recoit la destination. Ecrit `-j `, l'outil prenait le fichier pour l'APK et refusait le vrai chemin en argument surnumeraire. La forme erronee vient de `ci-et-chaine-de-publication.md` §6 — a corriger la-bas aussi. Ajout d'un controle explicite de presence du rapport, sinon `jq` echouait sur un fichier absent avec un message opaque. 2. `claude-review` : la requete etait rejetee en 440 ms pour un cout de 0 $, donc refusee avant tout traitement. L'action retenait `claude-opus-5[1m]`, variante a contexte etendu, faute de modele specifie. Desormais fixe explicitement. Laisser un defaut implicite choisir le modele en integration continue est de toute facon une mauvaise idee : le cout par revue et la disponibilite changent sans qu'aucun fichier du depot ne bouge. README passe en anglais (la version francaise suit dans README.fr.md), avec trois corrections de FOND — le traduire tel quel aurait propage des informations devenues fausses aujourd'hui : - « TOTP 2FA avec scanner QR » : le scanner a ete retire avec Google ML Kit ; - la permission `CAMERA` n'existe plus, `USE_FINGERPRINT` est documentee comme injectee par le plugin `biometric_storage` ; - `THREAT_MODEL.md` etait absent de la liste des documents. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-review.yml | 11 ++ .github/workflows/promesses.yml | 13 ++- README.md | 171 ++++++++++++++-------------- 3 files changed, 109 insertions(+), 86 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 2b15391..5289ecc 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -78,6 +78,17 @@ jobs: # que les commentaires soient signés par elle plutôt que par # `github-actions[bot]`. github_token: ${{ secrets.GITHUB_TOKEN }} + # CI 2026-08-04 — modèle FIXÉ explicitement. + # + # Sans cette ligne, l'action retenait `claude-opus-5[1m]`, la variante + # à contexte étendu, et la requête était rejetée en 440 ms pour un + # coût de 0 $ — donc refusée avant tout traitement, faute d'accès à ce + # modèle sur cette clé. + # + # Laisser un défaut implicite décider du modèle en intégration + # continue est de toute façon une mauvaise idée : le coût par revue et + # la disponibilité changent sans qu'aucun fichier du dépôt ne bouge. + claude_args: "--model claude-sonnet-5" # Prompt pour les auto-reviews PR (skip pour @claude mentions # qui suivent l'instruction du commentaire). prompt: | diff --git a/.github/workflows/promesses.yml b/.github/workflows/promesses.yml index 2138ff8..13821db 100644 --- a/.github/workflows/promesses.yml +++ b/.github/workflows/promesses.yml @@ -123,8 +123,19 @@ jobs: - name: Aucun tracker connu (Exodus Privacy) run: | APK=$(find build -name '*release*.apk' | head -1) + # CI 2026-08-04 — syntaxe corrigée. `-j` est un DRAPEAU (sortie JSON), + # il ne prend pas de nom de fichier ; c'est `-o` qui reçoit la + # destination. Écrit `-j `, l'outil prenait le fichier + # pour l'APK et refusait le vrai chemin en argument surnuméraire : + # exodus_analyze.py: error: unrecognized arguments: /w/…/app-release.apk + # La forme erronée vient de `ci-et-chaine-de-publication.md` §6, à + # corriger là-bas aussi. docker run --rm -v "$PWD:/w" exodusprivacy/exodus-standalone \ - -j /w/exodus.json "/w/$APK" || true + -j -o /w/exodus.json "/w/$APK" || true + if [ ! -f exodus.json ]; then + echo "ECHEC : Exodus n'a produit aucun rapport." + exit 1 + fi COUNT=$(jq '.trackers | length' exodus.json) echo "Trackers détectés : $COUNT" jq -r '.trackers[]?.name' exodus.json || true diff --git a/README.md b/README.md index 1d943a3..374edd2 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@

Pass Tech

- Gestionnaire de mots de passe Android 100 % local.
- Aucun cloud. Aucun tracker. Aucun compte. + 100% offline Android password manager.
+ No cloud. No tracker. No account.

@@ -17,113 +17,113 @@ Android 7+

-> Vos secrets ne quittent jamais votre téléphone. +

+ English · Français +

+ +> Your secrets never leave your phone. --- -## Pourquoi Pass Tech +## Why Pass Tech -La majorité des gestionnaires de mots de passe synchronisent vos données via leur cloud — ce qui implique une confiance totale dans le fournisseur. Pass Tech prend le parti opposé : **aucun serveur**, aucun compte, aucune fuite possible côté backend, parce qu'il n'y a pas de backend. +Most password managers sync your data through their cloud — which means trusting the provider completely. Pass Tech takes the opposite stance: **no server**, no account, no possible backend breach, because there is no backend. -- **100 % local** — coffre chiffré stocké uniquement dans la mémoire interne de l'app -- **Open source** — Apache License 2.0, code auditable -- **Crypto v4 hardened** — Argon2id + AES-GCM-256 + KEK liée au matériel (StrongBox/TEE) -- **Pack confidentialité radicale 5/5** — coffre leurre, mode panique, héritage, anti-phishing -- **Aucune permission inutile** — `INTERNET` uniquement pour l'update GitHub et le HIBP opt-in +- **100% local** — encrypted vault stored only in the app's internal storage +- **Open source** — Apache License 2.0, auditable code +- **Hardened v4 crypto** — Argon2id + AES-GCM-256 + hardware-bound KEK (StrongBox/TEE) +- **No Google libraries** — no Play Services, no ML Kit, no Firebase, no telemetry +- **Radical privacy pack** — decoy vault, panic mode, inheritance, anti-phishing +- **No pointless permissions** — `INTERNET` only for the GitHub update check and the opt-in HIBP lookup -## Fonctionnalités +## Features -- Mots de passe avec générateur configurable (caractères 8–64 ou phrases Diceware FR) -- TOTP 2FA (RFC 6238) avec scanner QR -- Cartes bancaires (numéro, CVV, expiration, PIN — affichage 3D) -- Notes sécurisées -- Recherche locale par titre, identifiant, URL ou contenu -- Audit de sécurité (faibles, doublons, anciens, sans 2FA) -- Vérification de fuites HIBP (k-anonymity, opt-in) -- Export / import du coffre chiffré (`.ptbak`) -- Mises à jour vérifiables via GitHub Releases (SHA-256 publié) +- Passwords with a configurable generator (8–64 characters, or French Diceware passphrases) +- TOTP 2FA (RFC 6238) — paste the `otpauth://` URI, the secret is extracted automatically +- Bank cards (number, CVV, expiry, PIN — 3D display) +- Secure notes +- Local search by title, username, URL or content +- Security audit (weak, duplicate, old, missing 2FA) +- HIBP breach check (k-anonymity, opt-in) +- Encrypted vault export / import (`.ptbak`) +- Verifiable updates via GitHub Releases (published SHA-256) -### Pack confidentialité radicale +### Radical privacy pack -- **Coffre leurre** — un 2ᵉ mot de passe ouvre un faux coffre crédible (déni plausible, timing aligné). -- **Mode panique** — verrouille tout, efface le presse-papiers et camoufle l'icône en « Calculatrice ». -- **Héritage post-inactivité** — un proche peut accéder au coffre après une période d'inactivité prolongée, sans cloud. -- **Anti-phishing par domaine** — vérifie le domaine du navigateur avant copie ; alerte sur typosquatting. -- **Biométrie hardware-bound** (optionnelle) — clé liée à Android Keystore, biométrie obligatoire pour lire. +- **Decoy vault** — a second master password opens a credible fake vault (plausible deniability, timing-aligned). +- **Panic mode** — locks everything, wipes the clipboard and disguises the icon as a working calculator. +- **Inheritance after inactivity** — a relative can reach the vault after a prolonged period of inactivity, without any cloud. +- **Domain anti-phishing** — checks the browser's domain before copying; warns on typosquatting. +- **Hardware-bound biometrics** (optional) — key tied to the Android Keystore, biometric authentication required to read it. -## Sécurité +## Security -| Composant | Choix (vault v4) | +| Component | Choice (vault v4) | |---|---| -| Dérivation de clé | **Argon2id** (RFC 9106) — m = 19 MiB, t = 2, p = 1, L = 32 (OWASP 2024) | -| Chiffrement | **AES-256-GCM** (NIST SP 800-38D), nonce 96 bits, tag 128 bits | -| Anti-downgrade | AAD GCM lie `version | alias KEK | paramètres KDF` | -| Clé liée matériel | **KEK AES/GCM/NoPadding 256** dans Android Keystore (StrongBox si dispo, fallback TEE) | -| Dérivation finale | `HKDF-SHA256(salt, pwHash || hwSecret, "pt:v4", 32)` | -| Déni plausible | 2 alias KEK créés systématiquement à l'install (timing aligné, salt dummy 32 o) | -| Biométrie | Android Keystore + BiometricPrompt CryptoObject (`setUserAuthenticationRequired(true)`) | -| Anti-brute-force | Verrouillage progressif après 5 échecs (30 s → 30 min) | -| Captures écran | `FLAG_SECURE` actif | -| Clipboard | Effacement auto + flag `IS_SENSITIVE` (Android 13+) | -| RASP | Détection root + émulateur + debugger | -| Wipe RAM | Clé maîtresse effacée après usage et au verrouillage | -| Signature APK | v2+ uniquement (anti-CVE-2017-13156 / Janus) | -| Updates | SHA-256 publié dans chaque release GitHub | - -Voir [SECURITY.md](SECURITY.md) pour le modèle de menace complet et le signalement de vulnérabilités. - -## Captures d'écran - - - +| Key derivation | **Argon2id** (RFC 9106) — m = 19 MiB, t = 2, p = 1, L = 32 (OWASP 2024) | +| Encryption | **AES-256-GCM** (NIST SP 800-38D), 96-bit nonce, 128-bit tag | +| Anti-downgrade | GCM AAD binds `version \| KEK alias \| KDF parameters` | +| Hardware-bound key | **AES/GCM/NoPadding 256 KEK** in the Android Keystore (StrongBox when available, TEE fallback) | +| Final derivation | `HKDF-SHA256(salt, pwHash \|\| hwSecret, "pt:v4", 32)` | +| Plausible deniability | Two KEK aliases always created at install; both vault files kept the same size | +| Biometrics | Android Keystore + BiometricPrompt CryptoObject (`setUserAuthenticationRequired(true)`) | +| Anti-brute-force | Progressive lockout after 5 failures (30 s → 30 min), anchored on `elapsedRealtime` | +| Screenshots | `FLAG_SECURE`, re-armed natively before the system takes its recents thumbnail | +| Clipboard | Auto-wipe + `IS_SENSITIVE` flag (Android 13+) | +| RASP | Root, emulator and debugger detection | +| RAM wipe | Master key wiped after use and on lock | +| APK signature | v2+ only (mitigates CVE-2017-13156 / Janus) | +| Updates | SHA-256 published in every GitHub release | -*Captures à venir.* +The permission set is pinned in [`android/expected-permissions.txt`](android/expected-permissions.txt) and verified **against the built APK on every commit**, together with an Exodus Privacy tracker check. -## Installation +See [THREAT_MODEL.md](THREAT_MODEL.md) for what is protected, against whom, **and what is not** — including the acknowledged limits. See [SECURITY.md](SECURITY.md) to report a vulnerability. -### Option 1 — Obtainium (recommandé, mises à jour auto) +## Screenshots -1. Installer [Obtainium](https://github.com/ImranR98/Obtainium/releases/latest) -2. Ajouter cette URL : `https://github.com/gitubpatrice/pass_tech` +*Coming soon.* -### Option 2 — APK direct +## Install -Télécharger `app-arm64-v8a-release.apk` depuis [la dernière release](https://github.com/gitubpatrice/pass_tech/releases/latest) (ABI **arm64-v8a**, Android 7.0+). +### Option 1 — Obtainium (recommended, automatic updates) -**Vérifier l'intégrité** : +1. Install [Obtainium](https://github.com/ImranR98/Obtainium/releases/latest) +2. Add this URL: `https://github.com/gitubpatrice/pass_tech` + +### Option 2 — Direct APK + +Download `app-arm64-v8a-release.apk` from [the latest release](https://github.com/gitubpatrice/pass_tech/releases/latest) (**arm64-v8a** ABI, Android 7.0+). + +**Verify integrity**: ```bash sha256sum app-arm64-v8a-release.apk ``` -Le hash doit correspondre à celui publié dans les notes de release. +The hash must match the one published in the release notes. -> **Samsung One UI 6.1+** : si l'install est bloquée, désactivez temporairement *Réglages → Sécurité et confidentialité → Auto Blocker*. +> **Samsung One UI 6.1+**: if the install is blocked, temporarily disable *Settings → Security and privacy → Auto Blocker*. ## Permissions -| Permission | Pourquoi | +| Permission | Why | |---|---| -| `INTERNET` | Vérification de mise à jour (GitHub Releases) et HIBP (k-anonymity, opt-in). Aucune autre requête réseau. | -| `USE_BIOMETRIC` / `USE_FINGERPRINT` | Déverrouillage biométrique optionnel via BiometricPrompt. | -| `CAMERA` | Scanner un QR code TOTP pour ajouter un 2FA. Flux caméra traité localement, jamais enregistré. | +| `INTERNET` | Update check (GitHub Releases) and HIBP breach check (k-anonymity, opt-in). No other network request. | +| `USE_BIOMETRIC` | Optional biometric unlock via BiometricPrompt. | +| `USE_FINGERPRINT` | Not declared by Pass Tech: re-added by the `biometric_storage` plugin. Required by `androidx.biometric` on API 24-27. | + +No location, no contacts, no media access, no external storage (beyond a deliberate export), **no camera**. -Pas de localisation, pas d'accès aux contacts, pas d'accès aux médias, pas de stockage externe (hors export volontaire). +`CAMERA` and `ACCESS_NETWORK_STATE` were removed on 2026-08-03 along with QR code scanning, which relied on Google ML Kit. A 2FA secret is now added by pasting the `otpauth://` URI that services display below their QR code. -## Compiler depuis les sources +## Build from source -Pré-requis : Flutter 3.x, SDK Dart `^3.11.5`, JDK 17, Android SDK avec `minSdk = 24`. +Requirements: Flutter 3.x, Dart SDK `^3.11.5`, JDK 17, Android SDK with `minSdk = 24`. ```bash flutter pub get flutter build apk --release --split-per-abi ``` -Build Android exigeant un keystore release configuré dans `android/key.properties` (non versionné) : +The Android release build requires a signing keystore configured in `android/key.properties` (not versioned): ```properties storePassword=... @@ -135,22 +135,23 @@ storeFile=../keystore.jks ## Documentation - [LICENSE](LICENSE) — Apache License 2.0 -- [PRIVACY.md](PRIVACY.md) / [PRIVACY.fr.md](PRIVACY.fr.md) — politique de confidentialité -- [TERMS.md](TERMS.md) / [TERMS.fr.md](TERMS.fr.md) — conditions d'utilisation -- [SECURITY.md](SECURITY.md) — modèle de menace et politique de signalement -- [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) — dépendances tierces -- [NOTICE](NOTICE) — mentions Apache 2.0 +- [THREAT_MODEL.md](THREAT_MODEL.md) — what is protected, against whom, and what is not +- [PRIVACY.md](PRIVACY.md) / [PRIVACY.fr.md](PRIVACY.fr.md) — privacy policy +- [TERMS.md](TERMS.md) / [TERMS.fr.md](TERMS.fr.md) — terms of use +- [SECURITY.md](SECURITY.md) — vulnerability reporting policy +- [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) — third-party dependencies +- [NOTICE](NOTICE) — Apache 2.0 attribution notices -## Liens +## Links -- [files-tech.com/pass-tech.php](https://www.files-tech.com/pass-tech.php) — page produit -- [Releases](https://github.com/gitubpatrice/pass_tech/releases) — APK signés -- [contact@files-tech.com](mailto:contact@files-tech.com) — support et signalement de vulnérabilités +- [files-tech.com/pass-tech.php](https://www.files-tech.com/pass-tech.php) — product page +- [Releases](https://github.com/gitubpatrice/pass_tech/releases) — signed APKs +- [contact@files-tech.com](mailto:contact@files-tech.com) — support and vulnerability reports -## Licence +## License Copyright 2026 Files Tech / Patrice Haltaya -Distribué sous Apache License, Version 2.0. Voir [LICENSE](LICENSE) pour le texte complet. +Distributed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text. -Pass Tech est fourni « tel quel », sans garantie d'aucune sorte. Les données stockées sont chiffrées avec votre mot de passe maître et liées à la KEK matérielle de votre appareil — **si vous perdez le mot de passe maître ou si l'appareil est réinitialisé/wipe Keystore, le coffre est irrécupérable**. Pensez à exporter régulièrement votre coffre chiffré (`.ptbak`). +Pass Tech is provided "as is", without warranty of any kind. Stored data is encrypted with your master password and bound to your device's hardware KEK — **if you lose the master password, or if the device is reset or its Keystore wiped, the vault is unrecoverable**. Export an encrypted backup (`.ptbak`) regularly. From 3297915212bd152b5d0410484645389539efaa6a Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 13:42:23 +0200 Subject: [PATCH 22/34] =?UTF-8?q?docs:=20README=20bilingue=20=E2=80=94=20v?= =?UTF-8?q?ersion=20francaise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `README.md` est passe en anglais (public GitHub et F-Droid international), `README.fr.md` porte le francais. Convention deja en place dans le depot pour PRIVACY et TERMS : `.md` = anglais, `.fr.md` = francais. Les deux fichiers ont une structure de sections IDENTIQUE et se lient mutuellement en en-tete, pour qu'une modification de l'un signale immediatement ce qui manque a l'autre. Trois corrections de FOND reportees des deux cotes — traduire l'ancien texte tel quel aurait propage des informations devenues fausses aujourd'hui : - « TOTP 2FA avec scanner QR » : le scanner a ete retire avec Google ML Kit ; - `CAMERA` n'est plus une permission de l'application, et `USE_FINGERPRINT` est documentee comme injectee par le plugin `biometric_storage` ; - `THREAT_MODEL.md` etait absent de la liste des documents. Ajout de « Aucune bibliotheque Google » aux arguments : c'est desormais vrai et verifie sur l'APK a chaque commit. Co-Authored-By: Claude Opus 5 (1M context) --- README.fr.md | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 README.fr.md diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000..bfb3956 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,157 @@ +

+ Pass Tech +

+ +

Pass Tech

+ +

+ Gestionnaire de mots de passe Android 100 % local.
+ Aucun cloud. Aucun tracker. Aucun compte. +

+ +

+ CI + License: Apache 2.0 + Latest release + Built with Flutter + Android 7+ +

+ +

+ English · Français +

+ +> Vos secrets ne quittent jamais votre téléphone. + +--- + +## Pourquoi Pass Tech + +La majorité des gestionnaires de mots de passe synchronisent vos données via leur cloud — ce qui implique une confiance totale dans le fournisseur. Pass Tech prend le parti opposé : **aucun serveur**, aucun compte, aucune fuite possible côté backend, parce qu'il n'y a pas de backend. + +- **100 % local** — coffre chiffré stocké uniquement dans la mémoire interne de l'app +- **Open source** — Apache License 2.0, code auditable +- **Crypto v4 durcie** — Argon2id + AES-GCM-256 + KEK liée au matériel (StrongBox/TEE) +- **Aucune bibliothèque Google** — ni Play Services, ni ML Kit, ni Firebase, ni télémétrie +- **Pack confidentialité radicale** — coffre leurre, mode panique, héritage, anti-hameçonnage +- **Aucune permission inutile** — `INTERNET` uniquement pour la vérification de mise à jour et le contrôle HIBP optionnel + +## Fonctionnalités + +- Mots de passe avec générateur configurable (8 à 64 caractères, ou phrases de passe Diceware en français) +- TOTP 2FA (RFC 6238) — collez l'URI `otpauth://`, le secret en est extrait automatiquement +- Cartes bancaires (numéro, CVV, expiration, PIN — affichage 3D) +- Notes sécurisées +- Recherche locale par titre, identifiant, URL ou contenu +- Audit de sécurité (faibles, doublons, anciens, sans 2FA) +- Vérification de fuites HIBP (k-anonymat, optionnelle) +- Export / import du coffre chiffré (`.ptbak`) +- Mises à jour vérifiables via GitHub Releases (SHA-256 publié) + +### Pack confidentialité radicale + +- **Coffre leurre** — un 2ᵉ mot de passe ouvre un faux coffre crédible (déni plausible, temps de réponse aligné). +- **Mode panique** — verrouille tout, efface le presse-papiers et camoufle l'icône en calculatrice fonctionnelle. +- **Héritage après inactivité** — un proche peut accéder au coffre après une période d'inactivité prolongée, sans aucun cloud. +- **Anti-hameçonnage par domaine** — vérifie le domaine du navigateur avant copie ; alerte sur le typosquattage. +- **Biométrie liée au matériel** (optionnelle) — clé liée à l'Android Keystore, authentification biométrique exigée pour la lire. + +## Sécurité + +| Composant | Choix (coffre v4) | +|---|---| +| Dérivation de clé | **Argon2id** (RFC 9106) — m = 19 Mio, t = 2, p = 1, L = 32 (OWASP 2024) | +| Chiffrement | **AES-256-GCM** (NIST SP 800-38D), nonce 96 bits, étiquette 128 bits | +| Anti-downgrade | L'AAD du GCM lie `version \| alias KEK \| paramètres KDF` | +| Clé liée au matériel | **KEK AES/GCM/NoPadding 256** dans l'Android Keystore (StrongBox si disponible, repli TEE) | +| Dérivation finale | `HKDF-SHA256(sel, pwHash \|\| hwSecret, "pt:v4", 32)` | +| Déni plausible | Deux alias KEK créés systématiquement à l'installation ; les deux fichiers de coffre gardent la même taille | +| Biométrie | Android Keystore + BiometricPrompt CryptoObject (`setUserAuthenticationRequired(true)`) | +| Anti-force-brute | Verrouillage progressif après 5 échecs (30 s → 30 min), ancré sur `elapsedRealtime` | +| Captures d'écran | `FLAG_SECURE`, réarmé en natif avant que le système ne prenne sa vignette | +| Presse-papiers | Effacement automatique + drapeau `IS_SENSITIVE` (Android 13+) | +| RASP | Détection root, émulateur et débogueur | +| Effacement RAM | Clé maîtresse effacée après usage et au verrouillage | +| Signature APK | v2+ uniquement (parade CVE-2017-13156 / Janus) | +| Mises à jour | SHA-256 publié dans chaque release GitHub | + +La liste des permissions est figée dans [`android/expected-permissions.txt`](android/expected-permissions.txt) et vérifiée **sur l'APK construit à chaque commit**, avec un contrôle de traceurs Exodus Privacy. + +Voir [THREAT_MODEL.md](THREAT_MODEL.md) pour ce qui est protégé, contre qui, **et ce qui ne l'est pas** — limites assumées comprises. Voir [SECURITY.md](SECURITY.md) pour signaler une vulnérabilité. + +## Captures d'écran + +*À venir.* + +## Installation + +### Option 1 — Obtainium (recommandé, mises à jour automatiques) + +1. Installer [Obtainium](https://github.com/ImranR98/Obtainium/releases/latest) +2. Ajouter cette URL : `https://github.com/gitubpatrice/pass_tech` + +### Option 2 — APK direct + +Télécharger `app-arm64-v8a-release.apk` depuis [la dernière release](https://github.com/gitubpatrice/pass_tech/releases/latest) (ABI **arm64-v8a**, Android 7.0+). + +**Vérifier l'intégrité** : +```bash +sha256sum app-arm64-v8a-release.apk +``` +Le hash doit correspondre à celui publié dans les notes de release. + +> **Samsung One UI 6.1+** : si l'installation est bloquée, désactivez temporairement *Réglages → Sécurité et confidentialité → Auto Blocker*. + +## Permissions + +| Permission | Pourquoi | +|---|---| +| `INTERNET` | Vérification de mise à jour (GitHub Releases) et contrôle HIBP (k-anonymat, optionnel). Aucune autre requête réseau. | +| `USE_BIOMETRIC` | Déverrouillage biométrique optionnel via BiometricPrompt. | +| `USE_FINGERPRINT` | Non déclarée par Pass Tech : réajoutée par le plugin `biometric_storage`. Nécessaire à `androidx.biometric` sur les API 24 à 27. | + +Pas de localisation, pas d'accès aux contacts, pas d'accès aux médias, pas de stockage externe (hors export volontaire), **pas de caméra**. + +`CAMERA` et `ACCESS_NETWORK_STATE` ont été retirées le 2026-08-03 avec le scan de QR code, qui reposait sur Google ML Kit. Un secret 2FA s'ajoute désormais en collant l'URI `otpauth://` que les services affichent sous leur QR code. + +## Compiler depuis les sources + +Pré-requis : Flutter 3.x, SDK Dart `^3.11.5`, JDK 17, Android SDK avec `minSdk = 24`. + +```bash +flutter pub get +flutter build apk --release --split-per-abi +``` + +La compilation Android en release exige un keystore de signature configuré dans `android/key.properties` (non versionné) : + +```properties +storePassword=... +keyPassword=... +keyAlias=... +storeFile=../keystore.jks +``` + +## Documentation + +- [LICENSE](LICENSE) — Apache License 2.0 +- [THREAT_MODEL.md](THREAT_MODEL.md) — ce qui est protégé, contre qui, et ce qui ne l'est pas +- [PRIVACY.md](PRIVACY.md) / [PRIVACY.fr.md](PRIVACY.fr.md) — politique de confidentialité +- [TERMS.md](TERMS.md) / [TERMS.fr.md](TERMS.fr.md) — conditions d'utilisation +- [SECURITY.md](SECURITY.md) — politique de signalement de vulnérabilités +- [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) — dépendances tierces +- [NOTICE](NOTICE) — mentions Apache 2.0 + +## Liens + +- [files-tech.com/pass-tech.php](https://www.files-tech.com/pass-tech.php) — page produit +- [Releases](https://github.com/gitubpatrice/pass_tech/releases) — APK signés +- [contact@files-tech.com](mailto:contact@files-tech.com) — support et signalement de vulnérabilités + +## Licence + +Copyright 2026 Files Tech / Patrice Haltaya + +Distribué sous Apache License, Version 2.0. Voir [LICENSE](LICENSE) pour le texte complet. + +Pass Tech est fourni « tel quel », sans garantie d'aucune sorte. Les données stockées sont chiffrées avec votre mot de passe maître et liées à la KEK matérielle de votre appareil — **si vous perdez le mot de passe maître, ou si l'appareil est réinitialisé ou son Keystore effacé, le coffre est irrécupérable**. Pensez à exporter régulièrement une sauvegarde chiffrée (`.ptbak`). From 79b9a04b593167da708b7510a57f4287cf74f3c0 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 13:57:55 +0200 Subject: [PATCH 23/34] ci: authentifier claude-review par l'abonnement, pas par cle API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le workflow reclamait `ANTHROPIC_API_KEY`. Le secret existait bel et bien, mais la requete etait rejetee en ~200 ms pour un cout de 0 $ — donc refusee a l'authentification, avant tout traitement, et cela avec deux modeles differents. Raison : l'abonnement Claude et l'API sont deux produits DISTINCTS, factures separement. Une cle API exige un compte API approvisionne, que payer l'abonnement ne fournit pas. Aucune valeur de `ANTHROPIC_API_KEY` ne pouvait donc fonctionner ici. `claude_code_oauth_token` est la voie prevue pour un abonnement ; le jeton se genere avec `claude setup-token` (« requires Claude subscription »). L'en-tete du fichier annoncait « ~0,10-0,50 EUR par review, tokens API Anthropic, paye a l'usage ». Cela n'a jamais correspondu a cette configuration, et c'est precisement ce malentendu qui a fait poser une cle API la ou il fallait un jeton d'abonnement. Corrige. `ANTHROPIC_API_KEY` peut desormais etre supprime des secrets du depot. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-review.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 5289ecc..8f8282c 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -1,7 +1,11 @@ name: Claude Review # Review automatique des PR par Claude Code (anthropics/claude-code-action). -# Coût : ~0.10-0.50 € par review (tokens API Anthropic, paye à l'usage). +# Tourne sur l'ABONNEMENT Claude (jeton OAuth), pas sur l'API : aucune +# facturation à l'usage. L'en-tête annonçait « ~0,10-0,50 € par review, tokens +# API Anthropic, payé à l'usage » — cela n'a jamais correspondu à cette +# configuration, et c'est ce malentendu qui a fait poser une clé API là où il +# fallait un jeton d'abonnement. # # Mention @claude dans une issue ou PR pour invoquer manuellement. # Auto-review activé sur opened/synchronize. @@ -58,7 +62,21 @@ jobs: - name: Run Claude Code uses: anthropics/claude-code-action@v1 with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # CI 2026-08-04 — authentification par ABONNEMENT et non par clé API. + # + # Le workflow réclamait `ANTHROPIC_API_KEY`. Le secret existait, mais + # la requête était rejetée en ~200 ms pour un coût de 0 $ — donc + # refusée à l'authentification, avant tout traitement, et cela avec + # deux modèles différents. + # + # Raison : l'abonnement Claude et l'API sont deux produits distincts, + # facturés séparément. Une clé API exige un compte API approvisionné, + # que payer l'abonnement ne fournit pas. + # + # `claude_code_oauth_token` est la voie prévue pour un abonnement. Le + # jeton se génère avec `claude setup-token` (« requires Claude + # subscription ») et vit dans le secret ci-dessous. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # CI 2026-08-04 — le jeton du workflow, au lieu de l'app GitHub. # # Sans lui, l'action tente d'obtenir un jeton via l'application From 62532224bc3cdfe02e89010409b5e2219322dcf3 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 14:03:43 +0200 Subject: [PATCH 24/34] sec: pas d'apprentissage clavier sur les champs de carte bancaire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit Gemini Pro (partiel — voir plus bas). Finding confirmee, et c'est un oubli du 2026-08-03 : ce jour-la, la meme protection avait ete signalee sur les champs de NOTES, corrigee sur les trois, et les champs de CARTE laisses de cote. Exactement le motif que ce chantier passe deux jours a traquer : la garde posee sur un jumeau, pas sur l'autre. Les claviers tiers et le dictionnaire personnel apprennent ce qui est saisi, le proposent ensuite dans d'AUTRES applications et le synchronisent parfois vers le nuage de leur editeur. Un numero de carte et un nom de titulaire n'ont rien a y faire. PERIMETRE EXACT — la finding disait « les champs de carte », c'etait trop large. Verification widget par widget : _holderCtrl TextField -> corrige _numberCtrl TextField -> corrige _expiryCtrl TextField -> corrige _issuerCtrl TextField -> corrige _cvvCtrl PasswordTextField -> DEJA protege (widget, l. 82-83) _pinCtrl PasswordTextField -> DEJA protege Une premiere passe avait ajoute les drapeaux aux six : erreur de compilation sur CVV et PIN, dont le composant ne les expose pas parce qu'il les pose lui-meme. Corrige apres verification, pas apres supposition. AUDIT GEMINI PRO : ECHEC A SIGNALER. Cinq lots sur six abandonnes en HTTP 503 (modele preview congestionne), soit 68 fichiers sur 73 NON audites. Le present correctif vient du seul lot passe. Ce n'est pas la relecture externe visee, et les zones que l'on voulait faire relire — Kotlin natif, rotation de cle, flot d'authentification — n'ont pas ete couvertes. Seconde finding du lot ecartee : « FLAG_SECURE relache sur les notes », signale pour la troisieme fois par un modele, arbitre deux fois comme un compromis assume et documente (Knox bloque le presse-papier sinon). flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screens/entry_edit_screen.dart | Bin 32148 -> 36007 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/lib/screens/entry_edit_screen.dart b/lib/screens/entry_edit_screen.dart index 7bd2570547cc3091f3f5b92d1fb69a4b90179d62..e35fd3249c2c62ecfc642dad270933dea1e29854 100644 GIT binary patch delta 9894 zcmeHNYiwM{b?%bln@cW5?vh--h9W60C32T9QG7qdhfInui4UokWa@JFlDzVAceVE} zMM`GlrUru8iYsdppl%W?N{s|Ino0%QrcGQ!Zi_T6f>I8W6loo`D$t~ek;F}cpsmrg z=bL-)?p>*(l^O^FB>ZtWJ9FmDIcLuK=FIGV^j+I$Urf1Ln`+Otzc5w$LVv!E{(FeT z8i>5KkI3yRo1Hh?oG<*iNN?~8TcKzX3;%UnEzxb;exko;^Adg2#vqRW(B`90)hRBw zJ)5x=B_sveXh@50&2L3yrV;6}^O=-NVp>me?*kKKYAhm0y-&(pjrwP8O;NovW;NRR zXHu}%-=>rmV{p5nhnAtnV0c50MwNhU=l_v1oNmwF#`68CeZ+h@6)L@w+C=mlf~M|i zq_%Gldd}>@=(BsE=${GtZ-N%5HReIGuJlqw3310BxFMY86SiGXE1^%HPpg^*+j2M@ zWJ`)VCT)bHiYBu@wju>pvM)ZUN*hXOHECc@i&EDBThhW>WHcHLM_~`IPM;*v>*0Kv?N2i6CcwsM z9dfxhB(2MaZlZUgMK10S@I2e$lmO))gD7LT=8;)- z9}C5T!T21*@wD}_*9iK5&aS2%vgOnetrhfWP6f3CIdw!ooP*IDAnQ`MOTqOr}62{rNX@%V^}sxrq3E z_8}2Fltgx}*vF{rrhS6we+oLBhiG~>?@*R51QGV7ylLty%AX=C?khtKc|M+un}E zK;K?y^UU5;MBmz5lNd-VoT0Ay!dap(7s6dTg~)10IWpW+|xx3T|h$R!Htix08(sDy`D6Gjkv{@ORb|Zm4?k=k^*kqB0!cZ&U zo9+^G9n&)Fu&u1DQWv13iDk^#9Ntlevu2?TZv7oW|J`?p+InF3mLH(@O8H_rF1-EFZi8`B7VK1pjQ8gt|TcUOwcRD?Ns34 z{ZR!nw6_vj_grNi1(QBm9O_IQ(uNK|LOKse1BwP5u?nSaJ~v9#dZ~P?Rpj9F|rSN{XM}`#DI2(SPRh=HbnHZ4Eg>w1_|#9dZ(r-$218+ zf2!JN`o%;-VL#LLB}f(8d(1gMZ|%xkq(DGdK{(LN6(dO*yQl7tj)rgZ~Tga=j|ri2}$5c)y`N}#+03yCh} z0J{GC05r`!h}>yCh$G{Jf`0WN4xE}sP_3~xG3}*BnDchyWCfHb;cP>pf+84{*J-E3 zkuO#}-ZV#iFE;@$f6@d;jCebVKIKJ8{An-p?w%JBo$5o(j`*61YCarlU-WeljVE`z z?j}_>>W)O=fg-|MMN!y(W&){(I$Zqq zqgk{}{$s`bTZaJo)R&GUp&G>HrymJq8-NF6dWHMo1jl$85`5Bs5#u3EYuo!i4n79!P1qZ zO$Nr9SSsEb9stu?{Dzjvr{&v?d*| zP45#MFfb`jh;hJc|8bzgjY*)$?a3x=$-r_o&!2+Sk}0S|I8Jn83U69J=p>&^!D~CN z5`JeY|It7=v2rE1&*liM+Qng6#wAJkt1gW`QR}a#QO=~#j1zqXKhZkCL2^;rf|zf* zsB;(h5Vo1AgM6ErpJhJQIyNM>n>&sp-$`$xrr%tA{Wx58d$N|sb5e1nkOR|NS0vK; zE60HdKRFKX_DA;fo)d80;)%qGWZ9ObC=hWMw{Z7dHgVjI9=DrXqB^wmr{{1l+L@ao zT0V~gGdPdJ>`z5IbD{S>{+R{vTwg#Ces=+NW9K5!Sr0tjxvwprpq{*wqeO3Rlvtd$ zu7oC)kW3E_ZDiupClS!r2<88MGLO1?mp!Q}F_?b$@aIkySj3nKErOH+#Hf|diZnFx zFMpb99|38qC`ccciP^(AKSSDkB;wE2fs<#kDIY%zSHCUV-A|yMcd~?k`iX98z4ZiK z_b}&nkndhwbn}HJ6jN80ut8s4g3Ri3ppkRHjW3+*BKo6qkUT6ws=gsNMVq#Tdh(V5 z4!z6GMD^YH-R0V!Z6|=~4?#hG*^flqEpB)GImB@{T54;LEQS1%5|vfsX%puT1$q4Jq{N)0ss126Ys*oU>Q_)%BY`ZSh_|p1<}-TDS0O*h zq)3D+x)22j^ULf)X-r8HM-N?0l@wKJl^*~{=LQ7g0t~(b$WX`eWXiQVtg_t6oDs4!#ss{T5G1bea=+?+0 zQOYFY9O6ruagv4K_^$n#c(u$>^CE@o}UQ-yl`Ya8$dWDaN&`TtV;g4{9bYP2lB7t z?3XCcr13g_E1ZFvrC*_&!?l%Gw67}%(^|T46fCdovz&ZXS6agHA6Lw9%vwzj$MdVj zyMSX?qC^n4z&IdSFOKm}#^?(ZDHNdRGdcxUar&pP^%6Djj=V5`K{O4`lG#3 z&5JA1ho|kiiDzH31^1-yL>)S&7CeBd7|)~Zw%{qcQc*C_|Izo4qXeUnWG6|xKqztW z*nFow2}UumgXaA|Ak=CEKega)cw`7Q&3^yz2}xt5J|w+ga^dwfktICNrbZdWA6itQR!&CC@_Q{l=4Z#w(XT=RK0Z!)JqOq#7jx z(Wir^;S};&#uqn+X?KIha|aoOSNlhIRAJmbbs^X32h%Ot2&ny@X2!ET!AVjQcbzK! zcsSd<>yWbMouQa}!`O-2lXb+AW8AsYW|a_G`EXEkB;Av-+5M6J_|sO>%9Z>}5eKcX zTTxbd;a7*{FVSJC7h=;F@XSC(SmwC+i2?;tq7K^=-R)K&o(_J|?x>w9RC_+`28C}0 zcDbI`^5-t11|SFQa^631E#+yO0O7{XI^+3G9Q@`BrGW{W{*#r^W{KswbcO18JeL|@ zSbSm=m$NrE=~?62R=M%TYywSnK*DpEc-C*Jv|3>=M4=S-ixEZd}3@@a0Rs_pvXwkZ#<2x!-v3ia*H-MK4`$q@_M{x&3EQ)XTS@%AD6hSzm(D?ZbGG!#BFR zj^3agTGiHAZy#&5e2GV_^re)}V$4bW_T+0%>AzLNn;y)HKho)D(Oh9mE5EdzyI0r1 HYR~@yt@Za& delta 7096 zcmd5>X>eQDbuLMX1i@V(Ab{ZV2m%C1f&d9FA_;=vBJLYR(xXHcLqo))^<)r0fd?+K z70PWSnUcpb^V!p}YbW+NO|#V2WTwd^aV*=DrWq%xqNLNNlOJ&$C(}BOJy!m>6DxJk zeG33mp{*auOfmCtAI?4Z-gD3LopUe#{)z0z-p_i}nx)+dn0ES0b^?x_&4!)lEIa>P zgVHSh&im!mIM^#@vdXcXWyM2T^|+el!q2m~EG@FK-Du4=7_}U?B1D2=Za5;wHy1Wz zJSWnZ7s2dmoX@VPAqI|1Bx8KsbsxX!lzxh*g@lV|1DyDFwiRz=oAuOjuEDFNra!vI z$KyhX*K#G7AJFjcrwR@*K(Ti9zC`LPRt!>#`yy-Ts`2%>-3Zybi;qp zi7-7+9CRy(Y9f#9q^!#J-C~g&Z z7NdbqEPoS@VYvc^r&tY zzt9EHTHb`k@@BlkJMoQjHv<*+S&l}SH}|M7PGsqAB=CK+H5k=fa7OQ9aVUQz<_Iw_ z&=3c{s<#m5y5EN1>$L?bt_CN{3_92hQ-~X84JkS&4I4~~Vl?7F>~O0_iuDGk7A1Zo znhqNqGkA%tF3WL8`GgoxE{nn%pB{e%Up(xRBSz-T7_z92R%qnnj3>^E$#{edo(huM zBN0B%`TYvwQ2)6<>iHod5sN0$e(!d3YaUmdtBd>P`Nudc8U6U2aSp#TPQYiX#z|AP zYQeuUP2##~=!CTNQVJ=S!+bDK#+Jz`WY|qs!+JMWjck>QScS22+h{99Yqh>og$mhaM5m-S5}9;srvfFctATIEJg zwcn)hNvIW%XW$kVs~hF)x_2JtEsRF!I5ho`@PD;S?%4~K@ zb;BV+Box3mYU=TfMUSsr?D)B*3b!l`sIRkNysi$bbxnAp&W=~>YVl@WEA-X@9Je;( zg4K@a>G?NnbG{-oVMdXy5~nRXi!9G>B$L)B+>|N@$DeFLr;N{1z;K)blhMe0aE%W! z7&~EWQ!wjsn*-Nu?h-{@1#X=9Uz-WW`r%S_8*;qT&{(}s8Tz&QCcIHU1($sq57@`> zUAqJC*d2w+5UOlQjaG*hi+i#%UEzf77@Cf74y+&!?IeaIeUG5oNa(Ces zcRl{o?T5X^f{B&^JlxWPms&<~u(eZlb2)+&ypoqwiJ9fE%yNUwpWv5ib6)(UbqX~e zlSa}Cm4X=bOqbD*-N=W}d+hk3$BLVtY53bbwGz8rf`<4AB}yN6Dwv>58jWop7sS&J zJl58qP7^}nDC6qk2(u~MyxrC=!v@na$DO81?<{4VV7P~C^*G^cH=3mdSZ4~tQ@9PW zeF3kvPeR{uOg5Tgra&|{PXh3A2B%CGj1Z|uhI1#-;i*hb%VIAigs8tkv9cF$cC^FT z>A*mzU6*2%-GLP1#3wtuWml7sn%v-foqpM!66=EGORR`t?i4*@$Y24s3N9>?r|7 z2IoowP7%rCGO*y~E|$D+^q7&=ZM`iek93dY(e5Ldoi*U~ZX^DuyB8fjU21SL127Zd zlRdR~rpJkY>1m`qT@8b`N%jGiBeilyp2donmEq_6EO_3lQR@q`%V{AT>fwf>;pA$> zp~bT?J$~&Shr4%@xnBA+7hQMY`g#T4TCc+odbRlXUJmc|k{b?@7JU|}eR?ntVc%hT zH{eNMDYbgM;yWa@fx&}#+m|Q3Yx~?%JKI;l+HBetV|}QvU21>QS3|8%ChK7K7fNKG z-$ktfpB=2gQ+^X;=}5U`TGHD{DK*GEwAl2h)zrC%Z(cadhX$h?wiVUZ>8 zSl#%$gGCaDZm3*peM6F7NO$>~1Rv&?MLr~ToFB@?m7#{z`;8$Ddza^N&}$Wr9F^xf zag?oUz~_&$IZ0D;qD$+E#nCPkX9ReLrlfQ{tS9Grhb(_)xP)oXyjOdy<{S}tiU=*j z8)mH(39PVj;*H^c8vJUqk-l#xn{ald7~3Q4IJmUdhW{S%b9X4k#;6rfkB-q}g?6k` zYA44kX`(Y@6ZqQL2$QU(YnNR!Y@eblFDh~zA8(dpTSX4({CPWcyg1%0LC1^Z)`K&2 zTJTEXF*(s7N3skY`0EJ=LyKZ*PYPPxt;O_k6(ZA`99kO>4s(Z;EoZ9j)nR?UdWd!6 zgH;>0CyVfIpaNf=Y{i?CqiC9%z}i#?Uf!z2%c~ajg<1|qq7g#zH!j$a6|JWw@<|-FsOdJt&g6#g&RMdxvVgZ{QUc<$GbWmeWWx2Cd=%er z!yjfeMEP;3))r9UH0?fQ{djObxw0Z`j8hen=;7{`aBOAPM|fqxw`R?RRYtrsTXF|j zl_65^T){`^3@-&VOj`%2CZ^p4{Af8g{vj&rohwYM=)hfUkgTJwef49Ii$+!pa0{~w z0nW{NMh0Y^jm;O)bY#+$#ta%IzdK27)*{bJa5g3iQMw``+mreZ5XA}Y0#(5aH8l2_ zH5=|QZ&%F0(XWEcyr?XjA74I^CT!q4#80I=xW+^5Dr?{P^TFY8C^CEl%Mj zYKuS7yNl=9;{kl}6O)L3Y6jOnH6l3}bF^236WPfcKWxf@%{ z0o+*bku=yxm5LnFc8$DCavdeA3mNt(dzW3;#ar)Cn}5sir%ilc(0+uIA+}P$ASahq zQVA7v8gX67OPRS8?+bM5yK~Gb8VbfkGt!OPha0jWeeK4;(Fte7Ov{!fTj_fy*>s?_ zo51WJB9^pMFRC&!7Fm0x+SGDt*gNp8=;3|!SEPnMtA8kby*k{pUY;(AS)$xsLt$sV zS1FVncs-_J=Owxbn;EUE-kUU8M>+$rERC}pMF{*Q%lk##jh^+%LAZG4EvQkuHCX;@H_ue#4YJ7MIH*r_@)#kMPt76*ig3i(Ve znaM$~QErJ?a%dsHWC?-z|EN!wWY#jYmXs=vK49FPCTNm}A8^t4{&ht``rby3iF$nb zf#U2mtmKVLm(b~`c=7!HsG=4O{~t0_&3;%*r`P?mQE5Jd0d~VL?RVek-<^wodC?B- zC0mu`K1$F_cdd#)iBttSF?-3FzExcz^j1rLC$=ux@OPK|?J55Y^P>THZ8u)qu4LVmR`K?B6$&m_!g0AzE>x9Tn3mbR?8Fx?x8Lb9o$?)#@=sDU zN!J)pU#V7WJo-(y5h3ATK}^WU5A`k|uV0b=3R0v|&zekyJsb^ll58GqKU}y+zW)R8 C1~X0o From 5c1bc5bcc8da1a6cc55a0c3db87680ef6f04934f Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 14:48:00 +0200 Subject: [PATCH 25/34] =?UTF-8?q?sec:=204=20findings=20critiques/elevees?= =?UTF-8?q?=20de=20l'audit=20GPT=20=E2=80=94=202=20pertes=20de=20donnees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relecture externe ciblee (ChatGPT) sur les 6 fichiers ecrits les 3-4 aout, que personne d'autre que moi n'avait relus. Rapport de bien meilleure qualite que les passes Gemini : citations litterales, scenarios concrets, et il dit explicitement ce qu'il ne peut pas verifier. F1 — CRITIQUE — PERTE DE DONNEES : coffre VIDE ecrit par-dessus le vrai Entre la verification du mot de passe et l'ecriture, la rotation fait un Argon2id complet — pres d'une seconde. Si `lock()` survient dans cet intervalle (arriere-plan, minuterie, ou MODE PANIQUE), il pose `_entries = []`. Or `_saveVaultV4` SERIALISE `_entries` : la rotation reecrivait le coffre avec une liste vide, chiffree sous le nouveau mot de passe. Toutes les entrees perdues, sans erreur, sans trace. La panique est le pire declencheur : elle appelle `lock()` precisement quand l'utilisateur est sous contrainte. F2 — CRITIQUE — PERTE DE DONNEES : le VRAI leurre ecrase `pt_decoy_configured` etait ecrit APRES la creation du vrai leurre, dans `setupDecoyVault` comme dans `ensureVaultLayout`. Un processus tue entre les deux laissait un vrai coffre avec un drapeau reste a 'false'. Plus tard, `_realignDummyDecoyIfSmaller` lisait exactement 'false', en concluait que `_b` etait factice, et l'ecrasait par un leurre aleatoire indechiffrable. Ma garde « le drapeau doit valoir EXACTEMENT 'false' » etait juste dans son intention et FAUSSE dans son hypothese : un 'false' PERIME est possible. On ne peut pas rendre la paire fichier + drapeau atomique — on choisit donc le sens dans lequel l'incoherence est benigne. Drapeau ecrit AVANT : le pire cas devient « l'app croit a un vrai leurre la ou il n'y en a pas », donc elle s'abstient. F4 — ELEVEE : une ouverture pouvait annuler une panique Meme fenetre, cote deverrouillage : deux Argon2id s'ecoulent avant l'application du resultat. Un `lock()` intervenu entre-temps etait ecrase, le coffre se rouvrait en memoire APRES la panique, et l'appelant recevait `success`. Le commentaire de `_lockGeneration` annoncait pourtant la regle — « toute operation longue [...] doit relever ce compteur ». Je l'avais appliquee a la verification de mot de passe et pas a l'ouverture elle-meme : un commentaire decrivant une regle que son propre fichier n'appliquait pas. Les trois relevent du meme motif que tout ce chantier, et cette fois c'est MOI qui l'ai commis : la garde posee sur un jumeau, pas sur l'autre. Restent a traiter du meme rapport : F3 (`_createSlot` sans rollback), F5 (nettoyage fail-closed non propage aux chemins biometrique et secondaire), F6 (mot de passe leurre identique au principal non refuse par le service), F7 (auto-lock si l'ancre elapsedRealtime est absente), F8 (camouflage qui se replie sur « non camoufle »), F9 (rotation commise signalee comme echouee). flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/services/vault_service.dart | 58 +++++++++++++++++++++++++++++++-- lib/services/vault_setup.dart | 44 ++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index c03ae39..f6fb1ee 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -418,11 +418,29 @@ class VaultService { 'et pas de suite ni de répétition triviale', ); } + // SEC 2026-08-04 (audit GPT F2) — le drapeau est écrit AVANT la création. + // + // L'ordre inverse ouvrait une fenêtre de PERTE DE DONNÉES : `_createSlot` + // remplaçait `_b` par un VRAI coffre leurre, puis on écrivait `'true'`. Un + // processus tué entre les deux laissait un vrai coffre sur le disque avec + // un drapeau resté à `'false'`. Plus tard, au premier franchissement de + // barreau de rembourrage, `_realignDummyDecoyIfSmaller` lisait exactement + // `'false'`, en concluait que `_b` était factice, et l'écrasait par un + // leurre aléatoire définitivement indéchiffrable. + // + // Ma garde « le drapeau doit valoir EXACTEMENT `'false'` » était juste dans + // son intention et fausse dans son hypothèse : un `'false'` PÉRIMÉ est + // possible. On ne peut pas rendre la paire fichier + drapeau atomique, donc + // on choisit le sens dans lequel l'incohérence est bénigne. + // + // Avec cet ordre, une interruption laisse `'true'` sans vrai leurre : le + // pire cas est que l'application croie à un vrai leurre là où il n'y a + // qu'un factice. Elle s'abstient alors de toucher à `_b` — on perd une + // régénération de rembourrage, jamais des données. + await _storage.write(key: _decoyConfiguredKey, value: 'true'); // `_createSlot(decoy)` écrase le fichier `_b` (leurre factice) par un VRAI // coffre leurre (contenu + mot de passe choisis par l'utilisateur). await _createSlot(_Slot.decoy, decoyPassword); - // v2.5.x (H1) — marque qu'un VRAI decoy existe désormais (pour l'UI). - await _storage.write(key: _decoyConfiguredKey, value: 'true'); } /// SEC F18 v2.5.4 — étiquette NEUTRE écrite dans le fichier et utilisée comme @@ -526,6 +544,10 @@ class VaultService { // compteur à zéro (`_onUnlockSuccess`), donc l'utilisateur légitime ne voit // jamais la différence. await _reserveUnlockAttempt(); + // SEC 2026-08-04 (audit GPT F4) — relevé avant les dérivations, comparé + // avant d'appliquer le résultat. Voir le raisonnement au point de + // comparaison. + final genAvantOuverture = _lockGeneration; // Déni plausible : on tente UNE PASSE Argon2id sur CHAQUE slot, même si // un slot précédent a matché. Sinon le timing révèle l'existence du // decoy (1× Argon2id = matché primary, 2× = matché decoy ou échec avec @@ -639,6 +661,38 @@ class VaultService { rethrow; } + // SEC 2026-08-04 (audit GPT F4) — un verrouillage survenu PENDANT + // l'ouverture doit l'emporter sur elle. + // + // Deux Argon2id s'écoulent avant d'arriver ici, soit près de deux secondes. + // Si `lock()` intervient dans cet intervalle — mise en arrière-plan, + // minuterie, ou MODE PANIQUE — l'ouverture reprenait ensuite et posait + // `_key`, `_entries` et `_isOpen = true` : **le coffre se rouvrait en + // mémoire APRÈS la panique**, et l'appelant recevait `success`. + // + // Le commentaire de `_lockGeneration` annonçait pourtant la règle : « toute + // opération longue qui prend un instantané de l'état du coffre [...] doit + // relever ce compteur avant, et renoncer à restaurer s'il a changé ». Je + // l'avais appliquée à la vérification de mot de passe et pas à l'ouverture + // elle-même — un commentaire décrivant une règle que son propre fichier + // n'appliquait pas. + // + // On efface les tampons gagnants et on rend `wrongPassword` : rien ne + // s'ouvre, et la décision de verrouiller est respectée. L'utilisateur + // légitime n'a qu'à ressaisir son mot de passe. + if (_lockGeneration != genAvantOuverture) { + if (winnerKey != null) SecretBytes.wipe(winnerKey); + if (winnerSalt != null) SecretBytes.wipe(winnerSalt); + if (winnerWrappedDek != null) SecretBytes.wipe(winnerWrappedDek); + if (winnerWrapNonce != null) SecretBytes.wipe(winnerWrapNonce); + winnerEntries = null; + _wipeKey(); + _entries = []; + _isOpen = false; + _activeSlot = null; + return UnlockResult.wrongPassword; + } + if (matchedSlot != null && winnerKey != null) { if (!winnerIsV3) { // F3 v2.4.4 — Application des side effects UNIQUEMENT pour le diff --git a/lib/services/vault_setup.dart b/lib/services/vault_setup.dart index b2fda6d..ca6fdf5 100644 --- a/lib/services/vault_setup.dart +++ b/lib/services/vault_setup.dart @@ -144,6 +144,9 @@ extension VaultSetup on VaultService { // produisait l'ancienne clé : l'étiquette AES-GCM ne pouvait plus se // vérifier. **Ni l'ancien ni le nouveau mot de passe n'ouvraient plus le // coffre** — perte définitive, à partir d'un simple disque plein. + // SEC 2026-08-04 (audit GPT F1) — relevé AVANT la dérivation, comparé juste + // avant l'écriture. Voir le raisonnement complet au point de comparaison. + final genAvantRotation = _lockGeneration; final Uint8List? previousKey = _key == null ? null : Uint8List.fromList(_key!); @@ -159,6 +162,31 @@ extension VaultSetup on VaultService { hwSecret: hwSecret, ); + // SEC 2026-08-04 (audit GPT F1) — CRITIQUE : un verrouillage survenu + // pendant la rotation ferait écrire un coffre VIDE par-dessus le vrai. + // + // Entre la vérification du mot de passe et ici, il s'écoule un Argon2id + // complet — près d'une seconde. Si `lock()` survient dans cet intervalle + // (mise en arrière-plan avec verrouillage immédiat, minuterie + // d'inactivité, ou MODE PANIQUE), il pose `_entries = []`. Or + // `_saveVaultV4` sérialise `_entries` : la rotation, qui ne consultait + // rien, réécrivait alors le coffre AVEC UNE LISTE VIDE, chiffrée sous le + // nouveau mot de passe. Toutes les entrées perdues, sans erreur, sans + // trace, et le nouveau mot de passe ouvrant un coffre vide. + // + // La panique est le pire déclencheur : elle appelle `lock()` précisément + // quand l'utilisateur est sous contrainte. + // + // J'avais ajouté `_lockGeneration` pour la VÉRIFICATION du mot de passe + // et son commentaire annonce « toute opération longue [...] doit relever + // ce compteur ». La rotation ne le faisait pas : le commentaire décrivait + // une règle que son propre fichier n'appliquait pas. + // + // On abandonne, sans rien écrire. La rotation n'a pas eu lieu, l'ancien + // mot de passe reste valide, et le verrouillage demandé est respecté. + if (_lockGeneration != genAvantRotation) { + throw StateError(VaultService.vaultBusy); + } _wipeKey(); _key = Uint8List.fromList(finalKey); @@ -259,11 +287,25 @@ extension VaultSetup on VaultService { } if (!newDecoy.existsSync() && oldDecoy.existsSync()) { // Un VRAI decoy existait (ancien schéma) → on le porte + marque le flag. - await oldDecoy.rename(newDecoy.path); + // + // SEC 2026-08-04 (audit GPT F2) — le drapeau est écrit AVANT le + // renommage, pour la même raison que dans `setupDecoyVault`. + // + // Dans l'ordre inverse, un processus tué entre les deux laissait le VRAI + // leurre en place sous son nouveau nom, avec un drapeau absent. Au + // démarrage suivant, `read(...) == 'true'` rendait `false` — l'absence + // devenant indistinguable d'un « pas de vrai leurre » — et l'étiquette du + // fichier, encore l'ancienne, déclenchait `_createDummyDecoy()`. Le vrai + // coffre leurre était écrasé sans recours. + // + // Écrit d'abord, le pire cas devient un drapeau `'true'` sans vrai leurre + // derrière : l'application s'abstient de toucher à `_b`. Incohérence + // bénigne au lieu d'une perte définitive. await VaultService._storage.write( key: VaultService._decoyConfiguredKey, value: 'true', ); + await oldDecoy.rename(newDecoy.path); } // 2. Pas de coffre principal (fresh install pré-createVault) : rien à faire, From f0cfea26b41360dfcd3e0a3ddd05bd3109974509 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 14:54:09 +0200 Subject: [PATCH 26/34] =?UTF-8?q?sec:=20fin=20de=20l'audit=20GPT=20?= =?UTF-8?q?=E2=80=94=20F3,=20F5,=20F6,=20F7,=20F8,=20F9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite du lot precedent (F1, F2, F4). Les 9 findings du rapport sont traitees. F3 — `_createSlot` posait l'etat global AVANT l'ecriture, sans rollback. Jumeau exact du defaut corrige la veille dans `changeMasterPassword` : la parade avait ete posee la-bas et pas ici. Scenario : on configure un coffre leurre depuis une session PRINCIPALE ouverte, `_saveVaultV4` echoue, et le service pretend desormais qu'un autre coffre est ouvert, vide, avec une cle qui ne correspond pas au disque. La session principale etait ecrasee en memoire par une operation qui avait echoue. Instantane + restauration. F5 — Le nettoyage fail-closed n'existait que sur le chemin principal. Les chemins biometrique et secondaire publiaient `_entries` et `_isOpen = true` AVANT des operations qui peuvent lever, et leur `catch` n'effacait que la cle. Resultat possible : « mot de passe incorrect » rendu, `_isOpen` a vrai, entrees DECHIFFREES en memoire, plus aucune cle — un etat qui se contredit lui-meme. Propage aux deux jumeaux. F6 — `setupDecoyVault` ne verifiait pas l'invariant que sa PROPRE documentation enonce (« le decoyPassword DOIT etre different du master password [...] l'appelant doit valider en amont »). L'ecran le fait, le service non. Si les deux coincident, la boucle d'ouverture garde le premier gagnant — toujours le principal — et le coffre leurre devient inatteignable. Verifie desormais par le service lui-meme. F7 — Auto-lock : mon commentaire affirmait « si le canal rend une valeur aberrante ou indisponible [...] on verrouille trop tot, jamais trop tard ». FAUX dans le cas prevu par le code : `elapsedRealtimeMs()` a `null` sautait tout le bloc, ne laissant que le `Stopwatch` — celui dont le meme fichier explique qu'il NE COMPTE PAS la veille profonde. Deux heures de sommeil, coffre toujours ouvert. Sans ancre fiable, on verrouille. F8 — `isDisguised` se repliait sur « non camoufle » en cas d'erreur, DEUX FOIS : cote Kotlin (`success(false)`) et cote Dart (`catch => false`). Le `catch` de l'appelant, qui pretendait s'abstenir « plutot que de trahir un camouflage actif », ne pouvait donc JAMAIS s'executer — l'app contactait le reseau en mode panique. Deux replis du mauvais cote, empiles, qui se masquaient l'un l'autre. La methode rend desormais `bool?` : l'incertitude est representable, et chaque appelant doit la traiter. F9 — L'ecriture du sel herite pouvait faire echouer une rotation DEJA acquise : l'ecran annoncait un echec alors que le coffre etait passe sous le nouveau mot de passe, et les deux nettoyages suivants etaient court-circuites — dont la suppression du `.bak` v3. Or si l'on change de mot de passe PARCE QUE l'ancien est compromis, laisser une sauvegarde dechiffrable avec cet ancien mot de passe annule tout le benefice. Rendue best-effort : elle n'est pas porteuse en v4. POINT DOUTEUX — tranche, ce n'est PAS une vulnerabilite. Une version forgee (99999) entre bien dans la branche v4, mais `_decryptVaultV4` teste l'egalite STRICTE et la rejette. GPT avait raison de ne pas le promouvoir. Le commentaire etait trompeur sur la portee reelle de la garde : corrige. flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/passtech/pass_tech/MainActivity.kt | 16 ++++- lib/main.dart | 42 +++++++++++-- lib/screens/settings_screen.dart | 2 +- lib/screens/unlock_screen.dart | 4 +- lib/services/panic_service.dart | 24 +++++-- lib/services/vault_service.dart | 25 ++++++++ lib/services/vault_setup.dart | 62 +++++++++++++++++-- lib/services/vault_unlock.dart | 50 +++++++++++++++ 8 files changed, 208 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt b/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt index d709719..f700b63 100644 --- a/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt +++ b/android/app/src/main/kotlin/com/passtech/pass_tech/MainActivity.kt @@ -249,7 +249,21 @@ class MainActivity : FlutterFragmentActivity() { // ENABLED ou (DEFAULT && manifest enabled=true). Manifest = false. result.success(state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) } catch (e: Exception) { - result.success(false) + // SEC 2026-08-04 (audit GPT F8) — une erreur remonte + // comme ERREUR, plus comme « non camouflé ». + // + // Avant, toute exception devenait `success(false)`. + // Le Dart appelant croit pourtant traiter l'échec en + // fail-closed : son `catch` s'abstient de vérifier + // les mises à jour « plutôt que de trahir un + // camouflage actif ». Ce `catch` n'était jamais + // atteint — l'erreur arrivait déguisée en réponse + // normale, et l'application contactait le réseau + // alors que le mode panique était actif. + // + // Répondre « je ne sais pas » là où l'on ne sait pas + // laisse l'appelant choisir le côté sûr. + result.error("DISGUISE_STATE_ERROR", e.message, null) } } else -> result.notImplemented() diff --git a/lib/main.dart b/lib/main.dart index cda7138..ed1bc74 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -172,6 +172,11 @@ class _PassTechAppState extends State with WidgetsBindingObserver { /// aberrante du canal on verrouille plus tôt plutôt que plus tard. int? _pausedAtMonoMs; int? _pausedAtBootMs; + + /// Temps écoulé retenu quand aucune horloge fiable n'est disponible. + /// Dépasse tous les délais de verrouillage proposés (le plus long est + /// 30 min), donc force le verrouillage sans cas particulier à écrire. + static const int _dureeInfinieMs = 1 << 40; static final Stopwatch _stopwatch = Stopwatch()..start(); /// v2.5.0 (F9) — guard cache session sur `_checkForUpdate`. @@ -216,7 +221,11 @@ class _PassTechAppState extends State with WidgetsBindingObserver { // une divulgation du nom de l'app — c'est l'existence même du trafic qui // contredit le camouflage. try { - if (await PanicService.isDisguised()) return; + // SEC 2026-08-04 (audit GPT F8) — on s'abstient dès que ce n'est + // pas explicitement `false`. `null` = état indéterminé : ne pas + // savoir si le camouflage est actif doit conduire au silence + // réseau, pas à une requête qui le trahirait. + if (await PanicService.isDisguised() != false) return; } catch (_) { // Canal indisponible : on s'abstient. Fail-CLOSED — mieux vaut sauter // une vérification de mise à jour que trahir un camouflage actif. @@ -348,11 +357,34 @@ class _PassTechAppState extends State with WidgetsBindingObserver { var elapsedMs = pausedMs == null ? 0 : _stopwatch.elapsedMilliseconds - pausedMs; - if (pausedBootMs != null) { + // SEC 2026-08-04 (audit GPT F7) — l'ABSENCE de l'ancre système verrouille. + // + // Mon commentaire affirmait « si le canal rend une valeur aberrante ou + // indisponible, on retombe sur le second — donc on verrouille trop tôt, + // jamais trop tard ». C'était FAUX dans le cas prévu par le code + // lui-même : quand `elapsedRealtimeMs()` rend `null` au passage en + // arrière-plan, `pausedBootMs` est nul, ce bloc est entièrement sauté, et + // il ne reste que le `Stopwatch` — celui dont ce fichier explique trois + // paragraphes plus haut qu'il NE COMPTE PAS la veille profonde. + // + // L'appareil pouvait donc dormir deux heures et revenir avec un temps + // écoulé de quelques minutes : le coffre restait ouvert. La source forte + // manquante donnait MOINS de sécurité, alors qu'elle doit en donner plus. + // + // Repli fail-closed : sans ancre fiable, on considère le temps écoulé + // comme infini et on verrouille. Le coût pour l'utilisateur légitime est + // une saisie de mot de passe ; le coût inverse est un coffre ouvert. + if (pausedMs != null && pausedBootMs == null) { + elapsedMs = _dureeInfinieMs; + } else if (pausedBootMs != null) { final nowBootMs = await MonotonicClock.elapsedRealtimeMs(); - // Un recul signalerait un redémarrage — impossible ici, le processus - // n'y survit pas — ou une réponse incohérente : on l'ignore. - if (nowBootMs != null && nowBootMs >= pausedBootMs) { + if (nowBootMs == null) { + // L'ancre existait au départ mais le canal ne répond plus : même + // raisonnement, on ne sait pas combien de temps s'est écoulé. + elapsedMs = _dureeInfinieMs; + } else if (nowBootMs >= pausedBootMs) { + // Un recul signalerait un redémarrage — impossible ici, le processus + // n'y survit pas — ou une réponse incohérente : on l'ignore. final bootElapsed = nowBootMs - pausedBootMs; if (bootElapsed > elapsedMs) elapsedMs = bootElapsed; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f2ceeca..0cbc195 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1654,7 +1654,7 @@ class _SettingsScreenState extends State // actif. Sans cette marque, la décoration poserait une carte vide sur // l'écran de tout le monde. _Undecorated( - child: FutureBuilder( + child: FutureBuilder( future: PanicService.isDisguised(), builder: (_, snap) { if (snap.data != true) return const SizedBox.shrink(); diff --git a/lib/screens/unlock_screen.dart b/lib/screens/unlock_screen.dart index a36c1d6..1144cd7 100644 --- a/lib/screens/unlock_screen.dart +++ b/lib/screens/unlock_screen.dart @@ -93,7 +93,7 @@ class UnlockScreenState extends State { /// être sorti de la calculatrice, donc le camouflage est de toute façon /// tombé. Et l'action ne touche QUE l'icône du lanceur — elle n'ouvre rien, /// ne déchiffre rien, ne révèle aucune donnée. - late final Future _disguisedFuture; + late final Future _disguisedFuture; @override void initState() { @@ -735,7 +735,7 @@ class UnlockScreenState extends State { // accessible SANS ouvrir le coffre. Voir // `_disguisedFuture`. N'apparaît que si le camouflage // est effectivement actif. - FutureBuilder( + FutureBuilder( future: _disguisedFuture, builder: (_, snap) { if (snap.data != true) { diff --git a/lib/services/panic_service.dart b/lib/services/panic_service.dart index 8db8be5..b148b15 100644 --- a/lib/services/panic_service.dart +++ b/lib/services/panic_service.dart @@ -98,12 +98,28 @@ class PanicService { } /// Indique si l'app est actuellement camouflée. - static Future isDisguised() async { + /// État du camouflage : `true` camouflé, `false` non camouflé, + /// **`null` = indéterminé**. + /// + /// SEC 2026-08-04 (audit GPT F8) — rend `bool?` et non plus `bool`. + /// + /// Cette méthode avalait toute erreur en rendant `false`, c'est-à-dire « non + /// camouflé » — la réponse la moins sûre. Et le natif faisait déjà la même + /// chose de son côté : le `catch` de l'appelant, qui prétendait s'abstenir + /// « plutôt que de trahir un camouflage actif », ne pouvait donc JAMAIS + /// s'exécuter. Deux replis du mauvais côté, empilés, qui se masquaient l'un + /// l'autre. + /// + /// L'incertitude est désormais représentable, et chaque appelant doit la + /// traiter explicitement : + /// - vérification de mise à jour : s'abstient dès que ce n'est pas `false` ; + /// - éléments d'interface : n'affichent que sur `true`, l'incertitude ne + /// coûtant rien de plus qu'un bouton masqué. + static Future isDisguised() async { try { - final r = await _channel.invokeMethod('isDisguised'); - return r ?? false; + return await _channel.invokeMethod('isDisguised'); } catch (_) { - return false; + return null; } } } diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index f6fb1ee..f09085b 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -418,6 +418,31 @@ class VaultService { 'et pas de suite ni de répétition triviale', ); } + // SEC 2026-08-04 (audit GPT F6) — l'invariant que cette fonction DOCUMENTE + // est désormais VÉRIFIÉ par elle. + // + // Sa propre doc dit « le decoyPassword DOIT être différent du master + // password [...] l'appelant doit valider en amont ». L'écran le fait ; le + // service, non. Une garantie confiée à une couche plus étroite que celle + // qui la promet est une garantie qui tombera : écran refait, test, + // automatisation, et l'invariant saute sans bruit. + // + // Conséquence si les deux mots de passe coïncident : la boucle + // d'ouverture parcourt les emplacements dans l'ordre et garde le PREMIER + // qui déchiffre — toujours le principal. Le coffre leurre et tout ce que + // l'utilisateur y aurait rangé deviennent alors inatteignables par le flux + // normal de l'application. + // + // ⚠️ `passwordMatchesPrimary` exige d'être appelé depuis le slot principal + // et respecte le verrouillage : il ne rend `true` que sur une vraie + // correspondance. Un `false` peut donc signifier « verrouillé » ; dans ce + // cas on laisse passer, l'écran ayant déjà fait le contrôle en amont. + if (await passwordMatchesPrimary(decoyPassword)) { + throw ArgumentError( + 'Decoy password : doit differer du mot de passe principal, ' + 'sinon le coffre leurre devient inatteignable', + ); + } // SEC 2026-08-04 (audit GPT F2) — le drapeau est écrit AVANT la création. // // L'ordre inverse ouvrait une fenêtre de PERTE DE DONNÉES : `_createSlot` diff --git a/lib/services/vault_setup.dart b/lib/services/vault_setup.dart index ca6fdf5..b952549 100644 --- a/lib/services/vault_setup.dart +++ b/lib/services/vault_setup.dart @@ -27,6 +27,25 @@ extension VaultSetup on VaultService { // Generate hwSecret (32 random bytes), wrap with KEK. final hwSecret = SecretBytes.randomBytes(32); Uint8List? finalKey; + // SEC 2026-08-04 (audit GPT F3) — instantané de la session AVANT mutation. + // + // Jumeau exact du défaut corrigé la veille dans `changeMasterPassword` : la + // parade avait été posée là-bas et pas ici. Cette fonction remplaçait + // l'état global — clé, entrées, emplacement actif — AVANT l'écriture, et + // son `finally` n'effaçait que des tampons. + // + // Scénario : on configure un coffre leurre depuis une session PRINCIPALE + // ouverte. `_createSlot` bascule aussitôt la session sur un leurre vide, + // puis `_saveVaultV4` échoue — disque plein, erreur d'E/S. L'utilisateur + // lit « échec », mais le service prétend désormais qu'un AUTRE coffre est + // ouvert, vide, avec une clé qui ne correspond pas au disque, tandis que + // les caches décrivent encore le principal. La session principale a été + // écrasée en mémoire par une opération qui a échoué. + final sessionKey = _key == null ? null : Uint8List.fromList(_key!); + final sessionEntries = List.from(_entries); + final sessionOpen = _isOpen; + final sessionSlot = _activeSlot; + var creationCommitted = false; try { final alias = _aliasFor(slot); final wrap = await _keystore.wrap(alias, hwSecret); @@ -59,7 +78,23 @@ extension VaultSetup on VaultService { value: base64Encode(salt), ); await _onUnlockSuccess(); + // Le coffre est écrit ET son sel persisté : la session en mémoire décrit + // désormais fidèlement le disque. Plus rien à annuler. + creationCommitted = true; } finally { + if (!creationCommitted) { + // Échec en cours de route : on remet la session dans l'état d'avant + // l'appel, au lieu de laisser un emplacement à demi ouvert. + _wipeKey(); + _key = sessionKey; + _entries = sessionEntries; + _isOpen = sessionOpen; + _activeSlot = sessionSlot; + } else if (sessionKey != null) { + // Création réussie : l'instantané est du matériel de clé, il ne doit + // pas attendre le ramasse-miettes. + SecretBytes.wipe(sessionKey); + } SecretBytes.wipe(pwHash); SecretBytes.wipe(hwSecret); if (finalKey != null) SecretBytes.wipe(finalKey); @@ -217,10 +252,29 @@ extension VaultSetup on VaultService { // V1 v2.4.0 — salt en storage écrit APRÈS save vault réussi : si le // process est tué entre les deux, on garde l'ancien salt cohérent // avec l'ancien vault au lieu d'un salt orphelin. - await VaultService._storage.write( - key: _saltKeyFor(slot), - value: base64Encode(salt), - ); + // + // SEC 2026-08-04 (audit GPT F9) — cette écriture ne peut plus faire + // échouer une rotation DÉJÀ ACQUISE. + // + // Elle n'est pas porteuse en v4 : le déverrouillage lit `kdf.salt` DANS + // LE FICHIER. Mais si elle levait, l'exception remontait jusqu'à l'écran, + // qui annonçait un échec — alors que le coffre était bel et bien passé + // sous le nouveau mot de passe. Pire, elle court-circuitait les deux + // nettoyages qui suivent la sortie du `try` : l'invalidation de + // l'enveloppe biométrique, et la suppression du `.bak` v3. + // + // Ce dernier point est le plus gênant : si l'on change de mot de passe + // PARCE QUE l'ancien est compromis, laisser derrière soi une sauvegarde + // v3 déchiffrable avec cet ancien mot de passe annule tout le bénéfice + // de la rotation. + try { + await VaultService._storage.write( + key: _saltKeyFor(slot), + value: base64Encode(salt), + ); + } catch (_) { + // Vestige v3 uniquement : son absence n'empêche aucun déverrouillage. + } } finally { if (!rotationCommitted) { // Échec en cours de route : on remet la session dans l'état EXACT diff --git a/lib/services/vault_unlock.dart b/lib/services/vault_unlock.dart index f63abdd..4e7d239 100644 --- a/lib/services/vault_unlock.dart +++ b/lib/services/vault_unlock.dart @@ -265,6 +265,18 @@ extension VaultUnlock on VaultService { if (version > VaultService._v3Version || version < 1) { // Forged version (e.g. 99999) or invalid : refuse. + // + // SEC 2026-08-04 (audit GPT, point douteux) — précision sur la portée + // réelle de cette garde, qui prêtait à confusion. + // + // Une version SUPÉRIEURE à 4 n'arrive jamais ici : la branche + // `version >= _currentVersion` ci-dessus l'a déjà captée. Le rejet + // effectif se fait alors dans `_decryptVaultV4`, qui teste l'égalité + // STRICTE (`raw['version'] != _currentVersion`) et rend `null`. + // + // Ce test-ci ne couvre donc en pratique que `version < 1`. La + // protection existe bien — à un autre endroit que ne le laissait + // croire ce commentaire. return UnlockResult.wrongPassword; } @@ -319,7 +331,26 @@ extension VaultUnlock on VaultService { } return UnlockResult.success; } catch (_) { + // SEC 2026-08-04 (audit GPT F5) — nettoyage COMPLET, pas seulement la + // cle. + // + // Ce chemin publie `_entries` et `_isOpen = true` AVANT d'avoir fini + // toutes les operations susceptibles de lever : decodage des metadonnees + // en cache, `_onUnlockSuccess()` qui ecrit en stockage securise. Si l'une + // echoue, ce `catch` n'effacait que `_key` et rendait `wrongPassword`. + // + // Le singleton restait alors dans un etat qui se contredit lui-meme : + // resultat « mot de passe incorrect », `_isOpen` a vrai, les entrees + // DECHIFFREES toujours en memoire, et plus aucune cle. Le coffre etait + // ferme du point de vue de l'appelant et ouvert du point de vue de + // l'application. + // + // Le chemin principal (`_unlockInternal`) possedait deja cette parade + // complete ; elle n'avait pas ete propagee a ses deux jumeaux. _wipeKey(); + _entries = []; + _isOpen = false; + _activeSlot = null; return UnlockResult.wrongPassword; } } @@ -687,7 +718,26 @@ extension VaultUnlock on VaultService { return UnlockResult.biometricInvalidated; } } catch (_) { + // SEC 2026-08-04 (audit GPT F5) — nettoyage COMPLET, pas seulement la + // cle. + // + // Ce chemin publie `_entries` et `_isOpen = true` AVANT d'avoir fini + // toutes les operations susceptibles de lever : decodage des metadonnees + // en cache, `_onUnlockSuccess()` qui ecrit en stockage securise. Si l'une + // echoue, ce `catch` n'effacait que `_key` et rendait `wrongPassword`. + // + // Le singleton restait alors dans un etat qui se contredit lui-meme : + // resultat « mot de passe incorrect », `_isOpen` a vrai, les entrees + // DECHIFFREES toujours en memoire, et plus aucune cle. Le coffre etait + // ferme du point de vue de l'appelant et ouvert du point de vue de + // l'application. + // + // Le chemin principal (`_unlockInternal`) possedait deja cette parade + // complete ; elle n'avait pas ete propagee a ses deux jumeaux. _wipeKey(); + _entries = []; + _isOpen = false; + _activeSlot = null; return UnlockResult.wrongPassword; } } From fed2e56b7917ed4e69eaba96189605ab380266c4 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 14:58:36 +0200 Subject: [PATCH 27/34] ci: rendre claude-review non bloquant en attendant sa resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce job n'a jamais reussi. L'authentification a ete corrigee par etapes — `github_token` fourni (le jeton d'app GitHub manquait), modele fixe explicitement, puis `claude_code_oauth_token` a la place d'une cle API que l'abonnement ne fournit pas. Chaque etape a fait progresser la requete, la duree passant de 227 ms a plus de 2 s, mais quelque chose reste refuse en aval sans message exploitable dans le journal. RAISON DU DRAPEAU, et ce n'est pas la commodite : il mettait au rouge CHAQUE pull request alors que les quatre autres jobs — analyse, 153 tests, permissions de l'APK, traceurs Exodus — sont verts. Un voyant rouge permanent finit par ne plus rien signifier : on cesse de le regarder, et le jour ou un VRAI echec arrive, il passe inapercu. C'est exactement le meme mecanisme que le `flutter test || echo "No tests yet"` retire de `ci.yml` au debut de cet audit, qui faisait passer le job au vert meme quand les tests echouaient. La revue automatique fait par ailleurs doublon avec ce qui a ete fait a la main : audit ligne a ligne, quatre passes Gemini, une relecture ChatGPT ciblee. A retirer des que le job reussit une fois. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/claude-review.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 8f8282c..a423c44 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -47,6 +47,26 @@ jobs: github.event.comment.author_association == 'COLLABORATOR')) runs-on: ubuntu-latest timeout-minutes: 15 + # CI 2026-08-04 — job NON BLOQUANT. + # + # L'authentification a été corrigée par étapes : `github_token` fourni (le + # jeton d'app GitHub manquait), modèle fixé explicitement, puis + # `claude_code_oauth_token` à la place d'une clé API que l'abonnement ne + # fournit pas. Chaque étape a fait progresser la requête — la durée est + # passée de 227 ms à plus de 2 s — mais quelque chose reste refusé en aval, + # sans message exploitable dans le journal. + # + # En attendant, ce job mettait au rouge CHAQUE pull request, alors que les + # quatre autres — analyse, 153 tests, permissions de l'APK, traceurs — sont + # verts. Un voyant rouge permanent finit par ne plus rien signifier : on + # cesse de le regarder, et le jour où un vrai échec arrive, il passe + # inaperçu. C'est la raison de ce drapeau, pas la commodité. + # + # La revue automatique fait par ailleurs doublon avec ce qui a été fait à la + # main : audit ligne à ligne, quatre passes Gemini, une relecture ChatGPT. + # + # À retirer dès que le job réussit une fois. + continue-on-error: true permissions: contents: write # pour commit des fixes si demandé pull-requests: write # pour commenter sur la PR From a142890322559b8fe6ac4e3bcf9c8616f4d78abf Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 15:03:29 +0200 Subject: [PATCH 28/34] sec: _createSlot annulait un verrouillage survenu pendant sa creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trouve en relisant MES PROPRES correctifs d'il y a une heure, avant meme le retour de Codex. C'est la QUATRIEME fois en deux jours que le motif « je corrige une faille et j'en cree une » se produit — et cette fois dans le correctif d'un defaut de ce motif exact. Le `finally` ajoute par f0cfea2 (audit GPT F3) restaure la session — cle, entrees, emplacement actif — quand la creation echoue. Il le faisait SANS CONDITION. Or `_createSlot` dure un Argon2id complet. Si `lock()` survient dans cet intervalle (mise en arriere-plan, minuterie d'inactivite, ou MODE PANIQUE) et que l'ecriture echoue ensuite, la restauration remettait le coffre principal OUVERT en memoire, apres la panique. Exactement le defaut que le correctif jumeau de `changeMasterPassword` corrigeait — pose la-bas, oublie ici, une heure plus tard. `_lockGeneration` est desormais releve avant l'Argon2id et compare dans le `finally`. S'il a change, on efface l'instantane et on laisse le coffre ferme : la decision de verrouiller prime sur la restauration d'une operation echouee. LECON, et elle est generale : apres avoir ecrit un correctif de securite, le relire SOI-MEME une fois de plus rapporte moins que de le faire relire par un tiers. Sur les 9 findings de l'audit GPT, 3 portaient sur des commentaires que j'avais ecrits, decrivant une regle que mon propre code n'appliquait pas. flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/services/vault_setup.dart | 36 ++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/services/vault_setup.dart b/lib/services/vault_setup.dart index b952549..63812da 100644 --- a/lib/services/vault_setup.dart +++ b/lib/services/vault_setup.dart @@ -41,6 +41,9 @@ extension VaultSetup on VaultService { // ouvert, vide, avec une clé qui ne correspond pas au disque, tandis que // les caches décrivent encore le principal. La session principale a été // écrasée en mémoire par une opération qui a échoué. + // SEC 2026-08-04 — relevé avant l'Argon2id, comparé dans le `finally`. + // Voir le raisonnement au point de comparaison. + final genAvantCreation = _lockGeneration; final sessionKey = _key == null ? null : Uint8List.fromList(_key!); final sessionEntries = List.from(_entries); final sessionOpen = _isOpen; @@ -85,11 +88,34 @@ extension VaultSetup on VaultService { if (!creationCommitted) { // Échec en cours de route : on remet la session dans l'état d'avant // l'appel, au lieu de laisser un emplacement à demi ouvert. - _wipeKey(); - _key = sessionKey; - _entries = sessionEntries; - _isOpen = sessionOpen; - _activeSlot = sessionSlot; + // + // ⚠️ SEC 2026-08-04 — SAUF si un verrouillage est survenu entre-temps. + // + // Ce `finally` a été ajouté une heure après le correctif jumeau de + // `changeMasterPassword`, et reproduisait le défaut que celui-ci + // corrigeait : `_createSlot` dure un Argon2id complet, pendant lequel + // `lock()` peut survenir — mise en arrière-plan, minuterie, ou MODE + // PANIQUE. Restaurer sans condition remettait alors le coffre principal + // OUVERT en mémoire, APRÈS la panique. La décision de verrouiller doit + // primer sur la restauration d'une opération qui a échoué. + // + // Dans ce cas on efface l'instantané et on laisse le coffre fermé : + // l'appelant verra l'exception, et l'utilisateur retrouvera son écran + // de déverrouillage — ce qu'il a demandé. + if (_lockGeneration != genAvantCreation) { + if (sessionKey != null) SecretBytes.wipe(sessionKey); + sessionEntries.clear(); + _wipeKey(); + _entries = []; + _isOpen = false; + _activeSlot = null; + } else { + _wipeKey(); + _key = sessionKey; + _entries = sessionEntries; + _isOpen = sessionOpen; + _activeSlot = sessionSlot; + } } else if (sessionKey != null) { // Création réussie : l'instantané est du matériel de clé, il ne doit // pas attendre le ramasse-miettes. From 1dd16316b9c69811da079e3b40d5479148afcab2 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 15:05:22 +0200 Subject: [PATCH 29/34] sec: le deverrouillage biometrique pouvait annuler une panique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinquieme et dernier chemin long depourvu de la garde `_lockGeneration` — et paradoxalement le plus expose des quatre. Les trois autres (ouverture par mot de passe, creation d'emplacement, rotation du mot de passe maitre) durent un ou deux Argon2id, soit une a deux secondes. Ici la fenetre reste ouverte tant que l'invite biometrique attend un doigt : plusieurs dizaines de secondes, sans borne. C'est la plus grande fenetre de toute l'application pour qu'un `lock()` s'intercale — et notamment un MODE PANIQUE declenche pendant que l'invite est affichee, ce qui est exactement le scenario que la panique existe pour couvrir. Sans la garde, le deverrouillage reprenait apres la panique et rouvrait le coffre en memoire. Il rend desormais `biometricCanceled` et laisse le coffre ferme. RECENSEMENT COMPLET, pour ne pas refaire l'erreur du perimetre trop etroit. Les cinq chemins qui prennent un instantane d'etat ou appliquent un resultat apres une operation longue relevent maintenant tous la generation : vault_unlock.dart:127 _passwordMatchesPrimaryInternal vault_unlock.dart:545 _unlockWithBiometricInternal <- celui-ci vault_service.dart:575 _unlockInternalUnguarded vault_setup.dart:46 _createSlot vault_setup.dart:210 changeMasterPassword Le commentaire de `_lockGeneration` enonce la regle depuis le debut : « toute operation longue qui prend un instantane de l'etat du coffre pour le restaurer ensuite doit relever ce compteur ». Elle n'etait appliquee qu'a un endroit sur cinq quand elle a ete ecrite. Elle l'est desormais partout. flutter analyze : 0 issue. 153 tests verts. Co-Authored-By: Claude Opus 5 (1M context) --- lib/services/vault_unlock.dart | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/services/vault_unlock.dart b/lib/services/vault_unlock.dart index 4e7d239..62d3301 100644 --- a/lib/services/vault_unlock.dart +++ b/lib/services/vault_unlock.dart @@ -529,6 +529,20 @@ extension VaultUnlock on VaultService { } Future _unlockWithBiometricInternal() async { + // SEC 2026-08-04 — dernier chemin long dépourvu de la garde de + // verrouillage, et paradoxalement le plus exposé des quatre. + // + // Les trois autres — ouverture par mot de passe, création d'emplacement, + // rotation — durent un ou deux Argon2id, soit une à deux secondes. Ici la + // fenêtre est ouverte tant que l'invite biométrique attend un doigt : + // plusieurs dizaines de secondes, sans borne. C'est la plus grande fenêtre + // de toute l'application pour qu'un `lock()` s'intercale, et notamment un + // MODE PANIQUE déclenché pendant que l'invite est à l'écran. + // + // Sans cette garde, le déverrouillage reprenait après la panique et + // rouvrait le coffre en mémoire. Relevé ici, comparé avant l'application du + // résultat. + final genAvantBio = _lockGeneration; if (await getLockoutRemaining() != null) return UnlockResult.lockedOut; try { final store = await _bioStorage(); @@ -662,6 +676,17 @@ extension VaultUnlock on VaultService { } if (ok) { + // SEC 2026-08-04 — un verrouillage pendant l'invite biométrique prime. + // `ok` a été calculé avec `_entries` et `_isOpen` déjà posés plus haut ; + // on les annule ici plutôt que de rendre un succès sur un coffre que + // l'utilisateur vient de demander à fermer. + if (_lockGeneration != genAvantBio) { + _wipeKey(); + _entries = []; + _isOpen = false; + _activeSlot = null; + return UnlockResult.biometricCanceled; + } _activeSlot = _Slot.primary; await _onUnlockSuccess(); // AUDIT 2026-08-03 — mêmes traitements de fin d'ouverture que sur le From 6c6621fb07254a265c90dfee39a01b1efc2b2b49 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 15:20:09 +0200 Subject: [PATCH 30/34] =?UTF-8?q?sec:=20relecture=20Codex=20=E2=80=94=203?= =?UTF-8?q?=20defauts=20dans=20mes=20propres=20correctifs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITIQUE — le cache meta manquait a l'instantane de _createSlot. _saveVaultV4 termine par _cachedSalt/_cachedWrappedDek/_cachedWrapNonce/ _cachedKdfParams = . Le rollback ajoute hier ne remettait que la cle, les entrees et l'emplacement actif : le cache continuait de decrire le LEURRE. Chaine complete, depuis une session PRINCIPALE ouverte : setupDecoyVault -> _createSlot(decoy) -> _saveVaultV4 reussit (cache bascule sur le leurre) -> l'ecriture du sel ou _onUnlockSuccess leve -> le finally remet la session sur le principal, cache reste leurre -> la moindre modification d'entree passe par le chemin rapide de _saveVault, qui n'exige que quatre valeurs non nulles, et reecrit le fichier PRINCIPAL en y annoncant le sel et l'enveloppe du LEURRE -> au deverrouillage suivant la derivation repart du mauvais sel et l'enveloppe est deballee avec la mauvaise KEK : le coffre principal ne s'ouvre plus jamais, avec aucun des deux mots de passe. La restauration efface les tampons sortants via _wipeUnlessSame : quand l'echec survient AVANT _saveVaultV4, le cache courant et l'instantane sont le MEME objet, et un effacement naif remettrait un sel nul en place — exactement la corruption que le correctif existe pour empecher. 7 tests couvrent cette garde. MOYENNE — setupDecoyVault ne distinguait pas « non » de « je ne sais pas ». passwordMatchesPrimary rend false dans trois cas qui ne signifient pas que les mots de passe different : emplacement actif autre que le principal, verrouillage anti-force-brute, deverrouillage concurrent. La garde ajoutee hier ne testait que la valeur de retour — donc elle reproduisait le defaut qu'elle pretendait corriger, l'invariant restant suspendu a la garde d'ECRAN. Les trois cas sont desormais explicites et bloquants, et vaultBusy est mappe a l'ecran comme il l'est deja pour le changement de mot de passe maitre. FAIBLE — un recul de l'ancre systeme etait ignore au lieu de verrouiller. C'etait le troisieme repli du bloc de verrouillage automatique et le seul a pencher du mauvais cote : ignorer revenait a ne garder que le Stopwatch, celui qui ne compte pas la veille profonde. Le cas devrait etre hors d'atteinte, mais un repli ne vaut que par le sens dans lequel il echoue. Deux points releves en verifiant : - _cachedKdfParams manquait aussi dans _tryUnlockSlot, alors que le chemin principal le pose. Les quatre champs decrivent le MEME fichier : l'omission privait ce chemin de la migration d'etiquette SEC F18, et aurait pu faire reecrire un coffre en annoncant une derivation qui n'est pas la sienne. - la documentation de MonotonicClock.elapsedRealtimeMs demandait a l'appelant de se rabattre sur nowMs. C'etait faux et dangereux a suivre : nowMs est une horloge murale, insensible aux reculs mais pas aux avances. Le seul repli correct est de traiter la duree comme inconnue. L'invariant qui rend la restauration du cache inutile dans changeMasterPassword est desormais ecrit a l'endroit ou un futur await le romprait. 160 tests (153 -> 160), flutter analyze sans issue. Co-Authored-By: Claude Opus 5 --- lib/main.dart | 22 ++++++- lib/screens/settings_screen.dart | 11 ++++ lib/services/monotonic_clock.dart | 17 +++++- lib/services/vault_service.dart | 57 ++++++++++++++++-- lib/services/vault_setup.dart | 72 +++++++++++++++++++++++ lib/services/vault_unlock.dart | 16 +++++ test/cache_meta_rollback_test.dart | 93 ++++++++++++++++++++++++++++++ 7 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 test/cache_meta_rollback_test.dart diff --git a/lib/main.dart b/lib/main.dart index ed1bc74..84dd084 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -383,10 +383,28 @@ class _PassTechAppState extends State with WidgetsBindingObserver { // raisonnement, on ne sait pas combien de temps s'est écoulé. elapsedMs = _dureeInfinieMs; } else if (nowBootMs >= pausedBootMs) { - // Un recul signalerait un redémarrage — impossible ici, le processus - // n'y survit pas — ou une réponse incohérente : on l'ignore. final bootElapsed = nowBootMs - pausedBootMs; if (bootElapsed > elapsedMs) elapsedMs = bootElapsed; + } else { + // SEC 2026-08-04 (relecture Codex) — un RECUL de l'ancre système + // verrouille, au lieu d'être ignoré. + // + // La version précédente écrivait « on l'ignore », et ignorer revenait + // à ne garder que le `Stopwatch` — celui qui NE COMPTE PAS la veille + // profonde. C'est le troisième repli de ce bloc, et c'était le seul à + // pencher du mauvais côté, alors que les deux autres, dix lignes plus + // haut, verrouillent précisément parce que l'ancre est inexploitable. + // + // La documentation de `MonotonicClock.elapsedRealtimeMs` demande + // d'ailleurs qu'un appelant qui persiste une valeur « détecte le + // recul et le traite de façon conservatrice ». Ce code le détectait + // sans le traiter. + // + // Le cas devrait être hors d'atteinte — `elapsedRealtime` ne recule + // qu'au redémarrage de l'appareil, auquel le processus ne survit pas. + // Mais un repli n'a de valeur que par le sens dans lequel il échoue, + // et celui-ci laissait un coffre ouvert. + elapsedMs = _dureeInfinieMs; } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0cbc195..8238c7b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -494,6 +494,17 @@ class _SettingsScreenState extends State SnackUtils.showInfo(messenger, t.decoyConfiguredSnack); // Retour au unlock screen Navigator.of(context).popUntil((r) => r.isFirst); + } on StateError catch (e) { + // SEC 2026-08-04 (relecture Codex) — `setupDecoyVault` refuse désormais + // de créer un leurre quand la comparaison avec le mot de passe principal + // ne peut pas ABOUTIR (verrouillage anti-force-brute en cours, opération + // concurrente). Même traitement que le changement de mot de passe + // maître : message dédié, pas de sentinel interne à l'écran. + if (!mounted) return; + SnackUtils.showError(context, messenger, switch (e.message) { + VaultService.vaultBusy => t.vaultBusyRetry, + _ => t.genericError('$e'), + }); } catch (e) { if (!mounted) return; SnackUtils.showError(context, messenger, t.genericError('$e')); diff --git a/lib/services/monotonic_clock.dart b/lib/services/monotonic_clock.dart index b013781..3d7d30b 100644 --- a/lib/services/monotonic_clock.dart +++ b/lib/services/monotonic_clock.dart @@ -69,11 +69,22 @@ class MonotonicClock { /// anti-force-brute, là où [nowMs] échouait en ouverture. /// /// Retourne `null` si la plateforme ne répond pas (hors Android, canal - /// indisponible en test) : l'appelant doit alors se rabattir sur [nowMs] et - /// accepter la limite documentée. + /// indisponible en test). + /// + /// ⚠️ SEC 2026-08-04 (relecture Codex) — cette ligne disait « l'appelant doit + /// alors se rabattre sur [nowMs] ». C'était FAUX, et dangereux à suivre : + /// [nowMs] est une horloge MURALE, insensible aux reculs mais pas aux + /// avances. S'en servir pour mesurer un temps passé en arrière-plan + /// laisserait un attaquant raccourcir ce temps en avançant l'horloge — + /// exactement ce que cette méthode existe pour empêcher. + /// + /// Le seul repli correct est de traiter la durée comme INCONNUE et d'en tirer + /// la conclusion la plus prudente. Le verrouillage automatique + /// (`main.dart`) considère alors le temps écoulé comme infini et verrouille. /// /// ⚠️ Se remet à ZÉRO au redémarrage de l'appareil. Un appelant qui persiste - /// une valeur doit détecter le recul et le traiter de façon conservatrice. + /// une valeur doit détecter le recul et le traiter de la même façon — comme + /// une durée inconnue, jamais comme un « rien ne s'est passé ». static const _rasp = MethodChannel('com.passtech.pass_tech/rasp'); static Future elapsedRealtimeMs() async { diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index f09085b..649528f 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -433,10 +433,35 @@ class VaultService { // l'utilisateur y aurait rangé deviennent alors inatteignables par le flux // normal de l'application. // - // ⚠️ `passwordMatchesPrimary` exige d'être appelé depuis le slot principal - // et respecte le verrouillage : il ne rend `true` que sur une vraie - // correspondance. Un `false` peut donc signifier « verrouillé » ; dans ce - // cas on laisse passer, l'écran ayant déjà fait le contrôle en amont. + // ⚠️ SEC 2026-08-04 (relecture Codex) — la garde n'a de valeur que si la + // vérification a pu ABOUTIR. + // + // `passwordMatchesPrimary` rend `false` dans trois cas qui ne signifient + // PAS « les mots de passe diffèrent » : emplacement actif autre que le + // principal, verrouillage anti-force-brute en cours, déverrouillage + // concurrent. Ne tester que la valeur de retour, c'était donc reproduire + // exactement le défaut que ce bloc prétend corriger : l'invariant restait + // suspendu à la garde d'ÉCRAN, seule capable de distinguer « non » de + // « je ne sais pas ». + // + // On rend ces trois cas explicites et bloquants. La conséquence d'un + // faux « non » n'est pas cosmétique : deux emplacements ouverts par le + // même mot de passe rendent le leurre inatteignable — la boucle + // d'ouverture garde toujours le premier qui déchiffre, c'est-à-dire le + // principal — et tout ce que l'utilisateur y aurait rangé est perdu. + // + // Aucun parcours légitime n'est touché : l'écran de réglages n'est + // accessible que sur une session ouverte, et le seul point d'appel y + // configure le leurre depuis le coffre PRINCIPAL. + if (!_isOpen || _activeSlot != _Slot.primary) { + throw StateError( + 'Decoy setup : exige une session PRINCIPALE ouverte, sans quoi la ' + 'comparaison avec le mot de passe principal ne peut pas aboutir', + ); + } + if (_unlockGate != null || await getLockoutRemaining() != null) { + throw StateError(vaultBusy); + } if (await passwordMatchesPrimary(decoyPassword)) { throw ArgumentError( 'Decoy password : doit differer du mot de passe principal, ' @@ -1130,6 +1155,15 @@ class VaultService { @visibleForTesting static int get paddingBaseRungForTest => _paddingBaseRungBytes; + /// Entrée de test pour la garde d'aliasing du cache méta. + /// + /// Ce garde-fou tient sur une seule comparaison d'identité, et se tromper de + /// sens y remplace un sel par des zéros — perte définitive du coffre. Il doit + /// donc être couvert par des tests, pas seulement par relecture. + @visibleForTesting + static void wipeUnlessSameForTest(Uint8List? sortant, Uint8List? conserve) => + _wipeUnlessSame(sortant, conserve); + /// Entrée de test pour le calcul de longueur base64 sans décodage. @visibleForTesting static int decodedLenFromBase64ForTest(String b64) { @@ -1413,6 +1447,21 @@ class VaultService { } } + /// Efface [sortant] SAUF s'il désigne le même tampon que [conserve]. + /// + /// SEC 2026-08-04 (relecture Codex) — garde-fou d'aliasing pour les + /// restaurations de cache méta. `SecretBytes.wipe` écrit des zéros DANS le + /// tampon : effacer une référence que l'on s'apprête à remettre en place + /// remettrait un sel nul dans le cache, et la première écriture suivante + /// annoncerait ce sel nul dans le fichier — coffre définitivement illisible. + /// + /// Le cas se produit dès qu'une opération échoue AVANT d'avoir remplacé le + /// cache : l'instantané et la valeur courante sont alors le MÊME objet. + static void _wipeUnlessSame(Uint8List? sortant, Uint8List? conserve) { + if (sortant == null || identical(sortant, conserve)) return; + SecretBytes.wipe(sortant); + } + // ── Crypto helpers (statics — re-utilisés par les parts) ──────────────────── // // v2.2.0 : les shims `_zero / _constEq / _randomBytes` ont été supprimés. diff --git a/lib/services/vault_setup.dart b/lib/services/vault_setup.dart index 63812da..6a6b600 100644 --- a/lib/services/vault_setup.dart +++ b/lib/services/vault_setup.dart @@ -48,6 +48,37 @@ extension VaultSetup on VaultService { final sessionEntries = List.from(_entries); final sessionOpen = _isOpen; final sessionSlot = _activeSlot; + // SEC 2026-08-04 (relecture Codex) — le cache méta fait partie de la + // session, et il manquait à l'instantané. C'est le défaut CRITIQUE de la + // version précédente de ce correctif. + // + // `_saveVaultV4` termine par `_cachedSalt/_cachedWrappedDek/` + // `_cachedWrapNonce/_cachedKdfParams = `. La + // restauration ci-dessous ne remettait que la clé, les entrées et + // l'emplacement actif : le cache continuait de décrire le LEURRE. + // + // Chaîne complète de la perte, depuis une session PRINCIPALE ouverte : + // 1. `setupDecoyVault` → `_createSlot(decoy)` ; + // 2. `_saveVaultV4` réussit → le cache passe sur le leurre ; + // 3. l'écriture du sel (ou `_onUnlockSuccess`) lève — stockage sécurisé + // indisponible, Keystore saturé ; + // 4. le `finally` remet la session sur le principal, cache resté leurre ; + // 5. la moindre modification d'entrée appelle `_saveVault`, dont le + // chemin rapide n'exige que quatre valeurs non nulles : il réécrit le + // fichier PRINCIPAL en y annonçant le sel et l'enveloppe du LEURRE, + // alors que le contenu est chiffré sous la clé du principal ; + // 6. au déverrouillage suivant, la dérivation repart du sel du leurre et + // l'enveloppe est déballée avec la mauvaise KEK : **le coffre + // principal ne s'ouvre plus jamais**, avec aucun des deux mots de + // passe. + // + // Ces quatre champs sont des RÉFÉRENCES, pas des copies : `_saveVaultV4` + // réaffecte, il ne modifie jamais les tampons en place. L'instantané reste + // donc valide sans duplication de matériel sensible. + final cacheSalt = _cachedSalt; + final cacheWrappedDek = _cachedWrappedDek; + final cacheWrapNonce = _cachedWrapNonce; + final cacheParams = _cachedKdfParams; var creationCommitted = false; try { final alias = _aliasFor(slot); @@ -109,12 +140,37 @@ extension VaultSetup on VaultService { _entries = []; _isOpen = false; _activeSlot = null; + // Le verrouillage a déjà vidé le cache méta, mais `_saveVaultV4` a pu + // le repeupler APRÈS lui : on le revide, sinon il survivrait au + // verrouillage en décrivant l'emplacement qui vient d'être écrit. + VaultService._wipeUnlessSame(_cachedSalt, cacheSalt); + VaultService._wipeUnlessSame(_cachedWrappedDek, cacheWrappedDek); + VaultService._wipeUnlessSame(_cachedWrapNonce, cacheWrapNonce); + if (cacheSalt != null) SecretBytes.wipe(cacheSalt); + if (cacheWrappedDek != null) SecretBytes.wipe(cacheWrappedDek); + if (cacheWrapNonce != null) SecretBytes.wipe(cacheWrapNonce); + _cachedSalt = null; + _cachedWrappedDek = null; + _cachedWrapNonce = null; + _cachedKdfParams = null; } else { _wipeKey(); _key = sessionKey; _entries = sessionEntries; _isOpen = sessionOpen; _activeSlot = sessionSlot; + // Le cache méta revient avec le reste de la session. L'effacement des + // tampons sortants passe par `_wipeUnlessSame` : si l'échec est + // survenu AVANT `_saveVaultV4`, le cache courant et l'instantané sont + // le MÊME objet, et un effacement direct remettrait un sel nul en + // place — exactement la corruption que ce bloc existe pour empêcher. + VaultService._wipeUnlessSame(_cachedSalt, cacheSalt); + VaultService._wipeUnlessSame(_cachedWrappedDek, cacheWrappedDek); + VaultService._wipeUnlessSame(_cachedWrapNonce, cacheWrapNonce); + _cachedSalt = cacheSalt; + _cachedWrappedDek = cacheWrappedDek; + _cachedWrapNonce = cacheWrapNonce; + _cachedKdfParams = cacheParams; } } else if (sessionKey != null) { // Création réussie : l'instantané est du matériel de clé, il ne doit @@ -310,6 +366,22 @@ extension VaultSetup on VaultService { // Sans cette restauration, `_key` gardait la clé neuve alors que le // fichier était resté sous l'ancienne : le premier ajout d'entrée // scellait l'incohérence et rendait le coffre définitivement illisible. + // + // ⚠️ INVARIANT À NE PAS ROMPRE — SEC 2026-08-04 (relecture Codex). + // + // Le cache méta (`_cachedSalt` / `_cachedWrappedDek` / + // `_cachedWrapNonce` / `_cachedKdfParams`) n'a PAS besoin d'être + // restauré ici, contrairement à `_createSlot` où son omission ouvrait + // une perte définitive du coffre principal. Raison : `_saveVaultV4` ne + // met ce cache à jour qu'APRÈS le renommage atomique, et + // `rotationCommitted = true` suit cet appel SANS aucun `await` + // intermédiaire. Il n'existe donc aucun état où le cache décrit la + // nouvelle dérivation alors que la rotation est annulée. + // + // Insérer un `await` entre `_saveVaultV4` et `rotationCommitted = true` + // ROMPRAIT cet invariant et rouvrirait exactement le défaut corrigé + // dans `_createSlot` : il faudrait alors instantané et restauration des + // quatre champs, comme là-bas. _wipeKey(); _key = previousKey; } else if (previousKey != null) { diff --git a/lib/services/vault_unlock.dart b/lib/services/vault_unlock.dart index 62d3301..a129149 100644 --- a/lib/services/vault_unlock.dart +++ b/lib/services/vault_unlock.dart @@ -251,6 +251,22 @@ extension VaultUnlock on VaultService { _cachedSalt = r.salt; _cachedWrappedDek = r.wrappedDek; _cachedWrapNonce = r.wrapNonce; + // SEC 2026-08-04 (relecture Codex, par extension) — le QUATRIÈME champ + // du cache manquait ici, alors que le chemin principal + // (`_unlockInternal`) le pose. Les quatre forment un lot indissociable : + // ils décrivent le MÊME fichier. + // + // Deux conséquences de l'omission : + // • `_migrateFileLabelIfLegacy`, appelée deux lignes plus bas, renonce + // quand les paramètres sont nuls — la migration d'étiquette SEC F18 + // ne s'est donc jamais faite par ce chemin ; + // • pire, si un autre emplacement était ouvert avant SANS + // verrouillage intermédiaire, les paramètres restaient ceux de + // l'ANCIEN coffre à côté du sel du NOUVEAU. Le chemin rapide de + // `_saveVault` n'exige que quatre valeurs non nulles : il aurait + // réécrit ce coffre en annonçant une dérivation qui n'est pas la + // sienne, le rendant illisible au déverrouillage suivant. + _cachedKdfParams = r.params; await _onUnlockSuccess(); // SEC F18 v2.5.4 — voir `_migrateFileLabelIfLegacy`. Ce chemin est // secondaire (`_unlockInternal` est le principal) mais doit migrer diff --git a/test/cache_meta_rollback_test.dart b/test/cache_meta_rollback_test.dart new file mode 100644 index 0000000..07bff45 --- /dev/null +++ b/test/cache_meta_rollback_test.dart @@ -0,0 +1,93 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pass_tech/services/vault_service.dart'; + +/// SEC 2026-08-04 (relecture Codex) — garde d'aliasing du cache meta. +/// +/// `_createSlot` prend un instantane des quatre champs de cache +/// (`_cachedSalt` / `_cachedWrappedDek` / `_cachedWrapNonce` / +/// `_cachedKdfParams`) et le restaure si la creation echoue. Sans cette +/// restauration, une session revenue au coffre PRINCIPAL conservait les +/// metadonnees du LEURRE, et la premiere ecriture suivante reecrivait le +/// fichier principal en annoncant le sel et l'enveloppe du leurre : coffre +/// definitivement illisible. +/// +/// La restauration efface les tampons sortants — mais `SecretBytes.wipe` ecrit +/// des zeros DANS le tampon. Quand l'echec survient AVANT `_saveVaultV4`, le +/// cache courant et l'instantane sont le MEME objet : un effacement naif +/// remettrait un sel nul en place, c'est-a-dire exactement la corruption que le +/// correctif existe pour empecher. D'ou `_wipeUnlessSame`, teste ici. +void main() { + Uint8List sel(int graine) => + Uint8List.fromList(List.generate(32, (i) => (graine + i) & 0xff)); + + bool estNul(Uint8List b) => b.every((o) => o == 0); + + group('garde d\'aliasing du cache meta', () { + test('n\'efface PAS le tampon que l\'on remet en place', () { + // Cas de l'echec AVANT `_saveVaultV4` : meme objet des deux cotes. + final courant = sel(1); + VaultService.wipeUnlessSameForTest(courant, courant); + expect( + estNul(courant), + isFalse, + reason: 'le sel restaure ne doit jamais etre zeroise', + ); + }); + + test('efface bien un tampon reellement sortant', () { + // Cas de l'echec APRES `_saveVaultV4` : le cache courant decrit le + // leurre, l'instantane decrit le principal. Le leurre doit partir. + final sortant = sel(1); + final conserve = sel(200); + VaultService.wipeUnlessSameForTest(sortant, conserve); + expect(estNul(sortant), isTrue); + expect(estNul(conserve), isFalse); + }); + + test('deux tampons de CONTENU identique restent distincts', () { + // Piege : la garde doit porter sur l'IDENTITE, pas sur l'egalite de + // contenu. Deux copies du meme sel sont deux objets ; effacer l'une ne + // doit pas dependre de leur contenu. + final sortant = sel(7); + final conserve = sel(7); + expect(sortant, equals(conserve)); + VaultService.wipeUnlessSameForTest(sortant, conserve); + expect(estNul(sortant), isTrue); + expect( + estNul(conserve), + isFalse, + reason: 'l\'instantane doit survivre meme s\'il a le meme contenu', + ); + }); + + test('tolere un tampon sortant nul', () { + // Cache vide (post-lock, ou creation du tout premier coffre). + final conserve = sel(3); + VaultService.wipeUnlessSameForTest(null, conserve); + expect(estNul(conserve), isFalse); + }); + + test('tolere un instantane nul et efface quand meme le sortant', () { + final sortant = sel(9); + VaultService.wipeUnlessSameForTest(sortant, null); + expect(estNul(sortant), isTrue); + }); + + test('null des deux cotes ne leve pas', () { + expect( + () => VaultService.wipeUnlessSameForTest(null, null), + returnsNormally, + ); + }); + + test('un tampon vide ne leve pas', () { + final vide = Uint8List(0); + expect( + () => VaultService.wipeUnlessSameForTest(vide, null), + returnsNormally, + ); + }); + }); +} From 1060923e134f92b3320fed256e27674bc5871a7d Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 15:22:07 +0200 Subject: [PATCH 31/34] docs: deux commentaires qui affirmaient encore le contraire du code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.dart annoncait toujours, juste au-dessus du bloc de verrouillage automatique, que se rabattre sur le Stopwatch faisait « verrouiller trop tot, jamais trop tard ». Le correctif F7 refutait cette phrase quinze lignes plus bas, mais la phrase restait la, en premier dans l ordre de lecture. Le Stopwatch ne mesure que le temps eveille : s y rabattre seul RALLONGE le delai. vault_setup.dart affirmait que rien ne modifie les tampons du cache meta en place. lock() le fait — il les zeroise — et c est precisement la raison du branchement qui suit. Commentaires seuls, aucun changement de comportement. 160 tests verts. Co-Authored-By: Claude Opus 5 --- lib/main.dart | 16 +++++++++++----- lib/services/vault_setup.dart | 10 ++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 84dd084..fe3a403 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -349,11 +349,17 @@ class _PassTechAppState extends State with WidgetsBindingObserver { // AUDIT 2026-08-03 — temps réellement écoulé en arrière-plan. // - // `elapsedRealtime` compte le sommeil profond, le `Stopwatch` non. On - // retient le PLUS GRAND des deux : en fonctionnement normal c'est - // toujours le premier, et si le canal rend une valeur aberrante ou - // indisponible, on retombe sur le second — donc on verrouille trop tôt, - // jamais trop tard. + // `elapsedRealtime` compte le sommeil profond, le `Stopwatch` non. Quand + // les deux ancres sont exploitables, on retient le PLUS GRAND ; en + // fonctionnement normal c'est toujours la première. + // + // ⚠️ Le `Stopwatch` ci-dessous n'est PAS un repli : il ne mesure que le + // temps où l'appareil était éveillé, donc s'y rabattre seul RALLONGE le + // délai avant verrouillage. Les trois cas où l'ancre système est + // inexploitable — absente au départ, absente au retour, ou en recul — + // sont traités juste en dessous, et tous les trois verrouillent. La + // version d'origine de ce commentaire affirmait l'inverse ; le + // raisonnement complet est en SEC 2026-08-04 (audit GPT F7). var elapsedMs = pausedMs == null ? 0 : _stopwatch.elapsedMilliseconds - pausedMs; diff --git a/lib/services/vault_setup.dart b/lib/services/vault_setup.dart index 6a6b600..23c7cb7 100644 --- a/lib/services/vault_setup.dart +++ b/lib/services/vault_setup.dart @@ -73,8 +73,14 @@ extension VaultSetup on VaultService { // passe. // // Ces quatre champs sont des RÉFÉRENCES, pas des copies : `_saveVaultV4` - // réaffecte, il ne modifie jamais les tampons en place. L'instantané reste - // donc valide sans duplication de matériel sensible. + // RÉAFFECTE `_cachedSalt` et ses voisins, il ne touche pas aux tampons + // précédents. L'instantané reste donc valide sans dupliquer du matériel + // sensible. + // + // ⚠️ Une seule chose les modifie en place : `lock()`, qui les zéroïse. Ce + // n'est pas un problème, c'est même la raison du branchement plus bas — si + // un verrouillage est survenu, l'instantané est justement ce qu'il ne faut + // PAS remettre en service. final cacheSalt = _cachedSalt; final cacheWrappedDek = _cachedWrappedDek; final cacheWrapNonce = _cachedWrapNonce; From a0e55bcfd4047e1cf01d1be7a863a3a29c7ad0dd Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 15:31:23 +0200 Subject: [PATCH 32/34] ui: le message de mot de passe faible chevauchait les boutons du dialogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signale en usage reel sur le dialogue du coffre leurre : « Mot de passe trop devinable... » passait par-dessus Annuler / Chiffrer. Ce n est pas un manque de marge, c est un DEBORDEMENT. Le contenu d un AlertDialog est enveloppe dans un Flexible ; le clavier est toujours ouvert ici (le premier champ prend le focus), donc la hauteur disponible tombe de plusieurs centaines de pixels. Le message fait alors passer la Column au-dela de sa boite, et une Column ne rogne pas : elle peint par-dessus les actions. Ajouter de la marge sous le texte aurait AGGRAVE le debordement. Parade structurelle : scrollable, la colonne recoit la hauteur restante et defile au lieu de deborder. La marge demandee vient en plus, pour que le message ne colle pas aux boutons quand tout tient. Applique AUSSI au dialogue de changement de mot de passe maitre : meme message, un champ de PLUS, donc il debordait plus tot. Ne corriger que celui qui a ete vu aurait laisse le defaut a l endroit le plus expose. setup_screen porte le meme message mais dans un Scaffold defilant : pas concerne. 160 tests verts, analyse propre. Co-Authored-By: Claude Opus 5 --- lib/screens/settings_screen.dart | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8238c7b..b50fa11 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1873,6 +1873,25 @@ class _PassphraseDialogState extends State<_PassphraseDialog> { final t = AppLocalizations.of(context); return AlertDialog( title: Text(widget.title), + // UI 2026-08-04 — signalé en usage réel : le message « Mot de passe trop + // devinable… » chevauchait les boutons Annuler / Chiffrer. + // + // Ce n'est pas un manque de marge, c'est un DÉBORDEMENT. Le contenu d'un + // `AlertDialog` est enveloppé dans un `Flexible` ; quand le clavier est + // ouvert — il l'est toujours ici, le premier champ prend le focus — la + // hauteur disponible tombe de plusieurs centaines de pixels. Le message + // d'erreur fait alors passer la colonne au-delà de sa boîte, et une + // `Column` ne rogne pas : elle peint par-dessus les actions. + // + // Ajouter de la marge sous le texte AGGRAVERAIT le débordement. La parade + // est de rendre le contenu défilable : la colonne reçoit alors la hauteur + // qui reste et défile au lieu de déborder. La marge ci-dessous vient en + // plus, pour que le message ne colle pas aux boutons quand tout tient. + // + // Le seuil dépend de la taille de police du système et de la longueur du + // message traduit : ce qui tient en français sur un grand écran peut + // déborder ailleurs. D'où une correction structurelle, pas un réglage. + scrollable: true, content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1902,6 +1921,9 @@ class _PassphraseDialogState extends State<_PassphraseDialog> { if (_error != null) ...[ const SizedBox(height: 8), Text(_error!, style: TextStyle(color: cs.error, fontSize: 12)), + // Marge sous le message : sans elle, un texte de trois lignes + // arrive au ras des boutons. + const SizedBox(height: 8), ], ], ), @@ -2044,6 +2066,14 @@ class _ChangePasswordDialogState extends State<_ChangePasswordDialog> { final t = AppLocalizations.of(context); return AlertDialog( title: Text(t.changePasswordDialogTitle), + // UI 2026-08-04 — JUMEAU du dialogue de phrase secrète, corrigé avec lui. + // + // Le débordement n'a été signalé que sur le coffre leurre, mais ce + // dialogue-ci porte le MÊME message `passwordTooWeak` avec UN CHAMP DE + // PLUS : il déborde donc plus tôt, pas plus tard. Ne corriger que celui + // qui a été vu, c'est laisser le défaut à l'endroit le plus exposé — le + // changement de mot de passe maître. + scrollable: true, content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -2067,6 +2097,7 @@ class _ChangePasswordDialogState extends State<_ChangePasswordDialog> { if (_error != null) ...[ const SizedBox(height: 8), Text(_error!, style: TextStyle(color: cs.error, fontSize: 12)), + const SizedBox(height: 8), ], ], ), From 1e00b1523b5731d8a040412431ed1acea21c135e Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 15:48:44 +0200 Subject: [PATCH 33/34] feat(audit): le score devient proportionnel et tient compte des fuites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre defauts de l ecran d audit, tous corriges. 1. LE SCORE IGNORAIT LA TAILLE DU COFFRE. Il soustrayait des points en valeur absolue avec un plafond : au-dela de six mots de passe faibles la penalite s arretait. Un coffre de six entrees dont les six etaient faibles affichait 70/100, « Bon » — et un coffre de deux cents entrees avec six faibles affichait exactement la meme chose. Le score rassurait la ou il ne fallait pas. Le nouveau modele est la sante MOYENNE des mots de passe, donc proportionnel par construction. 2. LE SEUL SIGNAL FACTUEL NE COMPTAIT PAS. Le resultat HaveIBeenPwned ne servait qu a peupler une liste : un mot de passe dont on a la PREUVE qu il figure dans une fuite publique valait zero penalite, pendant que l anciennete en coutait jusqu a vingt. Une entree compromise vaut desormais zero point de sante, le plus bas du bareme. 3. L ANCIENNETE PENALISAIT, a rebours du NIST SP 800-63B qui recommande explicitement de ne pas imposer de rotation periodique. Punir un mot de passe fort, unique et non compromis parce qu il a un an pousse a l increment (MotDePasse1 -> MotDePasse2), qui est plus faible. La section reste, en ton neutre et sans effet sur le score. 4. LES DEFAUTS SE CUMULAIENT SUR UNE MEME ENTREE. Un mot de passe a la fois faible ET reutilise etait compte deux fois. Une entree vaut maintenant son pire defaut, une seule fois. Aussi : - Un coffre SANS mot de passe n a plus de score. Il affichait 100 « Excellent », ce qui revenait a feliciter un coffre vide. - Le controle 2FA couvre « Reseaux sociaux » en plus de « Banque » et « Email » (rebond vers les autres services par reinitialisation), et son libelle dit desormais qu il ne voit que les TOTP stockes DANS Pass Tech — il produisait un faux positif silencieux chez qui utilise une application separee. - Le score est annonce PARTIEL tant qu au moins un mot de passe n a pas ete confronte aux fuites. La couverture est suivie separement du resultat : une premiere version de ce correctif annoncait « complet » des qu une verification avait tourne une fois, y compris apres l ajout d un mot de passe que personne n avait jamais verifie. - Les entrees compromises affichees sont reconstruites a chaque analyse : un mot de passe corrige disparait de la liste au lieu d y rester. Le calcul est extrait dans une fonction PURE (vault_audit_score.dart) et couvert par 16 tests — il n en avait aucun, et il etait faux. 176 tests (160 -> 176), analyse propre, parite FR/EN verifiee. Co-Authored-By: Claude Opus 5 --- lib/l10n/app_en.arb | 13 +- lib/l10n/app_fr.arb | 13 +- lib/screens/audit_screen.dart | 222 +++++++++++++++++++++++----- lib/services/vault_audit_score.dart | 105 +++++++++++++ test/vault_audit_score_test.dart | 157 ++++++++++++++++++++ 5 files changed, 461 insertions(+), 49 deletions(-) create mode 100644 lib/services/vault_audit_score.dart create mode 100644 test/vault_audit_score_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 424c5fc..db66dc6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -227,9 +227,12 @@ "auditScoreGood": "Good", "auditScoreMedium": "Medium", "auditScoreWeak": "Weak", + "auditScoreNone": "Not applicable", + "auditScoreNoneHint": "Add passwords to get a score.", + "auditScorePartial": "Partial score: the breach check has not been run yet.", "auditScoreLabel": "Security score", "auditScorePerfect": "No issue detected 🎉", - "auditScoreImprovements": "Recommended improvements below", + "auditScoreImprovements": "Average health of your passwords", "auditStatEntries": "Entries", "auditStatPasswords": "Passwords", "auditStatWith2fa": "With 2FA", @@ -253,11 +256,11 @@ "auditIssueDuplicateDesc": "Same password used on multiple accounts", "auditIssueDuplicateEmpty": "No reused password", "auditIssueNo2faTitle": "No 2FA on sensitive account", - "auditIssueNo2faDesc": "Bank or Email without TOTP code configured", + "auditIssueNo2faDesc": "Sensitive account with no TOTP code in Pass Tech (ignore if you use a separate authenticator app)", "auditIssueNo2faEmpty": "All your sensitive accounts have 2FA", - "auditIssueOldTitle": "Old (> 1 year)", - "auditIssueOldDesc": "Consider rotating them", - "auditIssueOldEmpty": "No old password", + "auditIssueOldTitle": "Not reviewed for over a year", + "auditIssueOldDesc": "For information — a strong, unique password does not need changing without a reason", + "auditIssueOldEmpty": "All your passwords were reviewed within the year", "entryDetailAntiPhishingInactive": "⚠ Anti-phishing inactive — check accessibility in Settings", "entryDetailAntiPhishingSettings": "Settings", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ea5fa92..97c9115 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -223,9 +223,12 @@ "auditScoreGood": "Bon", "auditScoreMedium": "Moyen", "auditScoreWeak": "Faible", + "auditScoreNone": "Non applicable", + "auditScoreNoneHint": "Ajoutez des mots de passe pour obtenir un score.", + "auditScorePartial": "Score partiel : la vérification des fuites n'a pas encore été lancée.", "auditScoreLabel": "Score de sécurité", "auditScorePerfect": "Aucun problème détecté 🎉", - "auditScoreImprovements": "Améliorations recommandées ci-dessous", + "auditScoreImprovements": "Moyenne de santé de vos mots de passe", "auditStatEntries": "Entrées", "auditStatPasswords": "Mots de passe", "auditStatWith2fa": "Avec 2FA", @@ -248,11 +251,11 @@ "auditIssueDuplicateDesc": "Même mot de passe utilisé sur plusieurs comptes", "auditIssueDuplicateEmpty": "Aucun mot de passe réutilisé", "auditIssueNo2faTitle": "Sans 2FA sur compte sensible", - "auditIssueNo2faDesc": "Banque ou Email sans code TOTP configuré", + "auditIssueNo2faDesc": "Compte sensible sans code TOTP dans Pass Tech (à ignorer si vous utilisez une autre application d'authentification)", "auditIssueNo2faEmpty": "Tous vos comptes sensibles ont un 2FA", - "auditIssueOldTitle": "Anciens (> 1 an)", - "auditIssueOldDesc": "Pensez à les renouveler", - "auditIssueOldEmpty": "Aucun mot de passe ancien", + "auditIssueOldTitle": "Non revus depuis plus d'un an", + "auditIssueOldDesc": "Pour information — un mot de passe fort et unique n'a pas à être changé sans raison", + "auditIssueOldEmpty": "Tous vos mots de passe ont été revus dans l'année", "entryDetailAntiPhishingInactive": "⚠ Anti-phishing inactif — vérifiez l'accessibilité dans Réglages", "entryDetailAntiPhishingSettings": "Réglages", diff --git a/lib/screens/audit_screen.dart b/lib/screens/audit_screen.dart index 423df2f..853338b 100644 --- a/lib/screens/audit_screen.dart +++ b/lib/screens/audit_screen.dart @@ -4,6 +4,7 @@ import '../models/category.dart'; import '../models/entry.dart'; import '../services/breach_service.dart'; import '../services/password_strength_service.dart'; +import '../services/vault_audit_score.dart'; import '../services/vault_service.dart'; import '../utils/snack_utils.dart'; import 'entry_detail_screen.dart'; @@ -16,14 +17,35 @@ class AuditScreen extends StatefulWidget { } class _AuditScreenState extends State { - static const _sensitiveCategories = {'Banque', 'Email'}; - late List _all; late List _weak; late List _duplicates; late List _old; late List _missing2fa; - late int _score; + + /// `null` quand le coffre ne contient aucun mot de passe : il n'y a alors + /// rien à noter. L'ancien calcul répondait `100`, « Excellent » — féliciter + /// un coffre vide. + int? _score; + + /// Points de santé par entrée, AVANT prise en compte des fuites. Conservés + /// pour pouvoir recalculer le score quand la vérification HIBP rend son + /// résultat, sans refaire les analyses coûteuses (entropie, doublons). + final Map _pointsBase = {}; + + /// Mots de passe reconnus dans une fuite publique. + Set? _breachedPasswords; + + /// Mots de passe RÉELLEMENT soumis à la vérification, qu'ils soient sortis + /// compromis ou non. + /// + /// Ne pas confondre avec [_breachedPasswords] : c'est la couverture, pas le + /// résultat. Sans elle, une première version de ce correctif annonçait un + /// score complet dès qu'une vérification avait tourné UNE FOIS — y compris + /// après l'ajout d'un mot de passe que personne n'avait jamais confronté aux + /// fuites. L'écran affirmait alors « vérifié » sur une donnée absente, ce qui + /// est exactement le défaut que le drapeau « partiel » existe pour éviter. + Set? _checkedPasswords; // P6 v2.4.4 — compteurs aggregés en single-pass dans `_analyze()`. Avant : // 4 `.where().length` recalculés à CHAQUE build (refresh, breach progress // setState) sur _all, soit 4×N evals par rebuild en plus des 4 passes @@ -67,6 +89,7 @@ class _AuditScreenState extends State { int with2fa = 0; // 1ʳᵉ passe : compteurs + remplissage `counts` pour détection doublons. + final weakIds = {}; for (final e in _all) { switch (e.type) { case EntryType.password: @@ -74,10 +97,13 @@ class _AuditScreenState extends State { if (e.totpSecret.isNotEmpty) with2fa++; if (e.password.isNotEmpty) { counts[e.password] = (counts[e.password] ?? 0) + 1; - if (PasswordStrengthService.isWeak(e.password)) weak.add(e); + if (PasswordStrengthService.isWeak(e.password)) { + weak.add(e); + weakIds.add(e.id); + } } if (e.updatedAt.isBefore(yearAgo)) old.add(e); - if (_sensitiveCategories.contains(e.category) && + if (VaultAuditScore.sensitiveCategories.contains(e.category) && e.totpSecret.isEmpty) { missing2fa.add(e); } @@ -90,13 +116,23 @@ class _AuditScreenState extends State { break; } } - // 2ᵉ passe (post-counts) : doublons confirmés. + final missing2faIds = {for (final e in missing2fa) e.id}; + // 2ᵉ passe (post-counts) : doublons confirmés + barème par entrée. + _pointsBase.clear(); for (final e in _all) { - if (e.type == EntryType.password && - e.password.isNotEmpty && - (counts[e.password] ?? 0) > 1) { - duplicates.add(e); - } + if (e.type != EntryType.password || e.password.isEmpty) continue; + final isDuplicate = (counts[e.password] ?? 0) > 1; + if (isDuplicate) duplicates.add(e); + // Les fuites sont volontairement ABSENTES d'ici : elles ne sont connues + // qu'après la vérification réseau, et elles s'appliquent par-dessus dans + // `_computeScore`. Ce barème-ci ne dépend que du contenu du coffre, donc + // il n'a pas à être recalculé à chaque résultat HIBP. + _pointsBase[e.id] = VaultAuditScore.pointsFor( + compromised: false, + weak: weakIds.contains(e.id), + duplicate: isDuplicate, + sensitiveWithout2fa: missing2faIds.contains(e.id), + ); } _weak = weak; @@ -108,13 +144,62 @@ class _AuditScreenState extends State { _cardCount = cardCount; _with2fa = with2fa; - // Score - int s = 100; - s -= _weak.length.clamp(0, 6) * 5; // -5 each, max -30 - s -= _duplicates.length.clamp(0, 6) * 5; // -5 each, max -30 - s -= _old.length.clamp(0, 4) * 5; // -5 each, max -20 - s -= _missing2fa.length.clamp(0, 4) * 5; // -5 each, max -20 - _score = s.clamp(0, 100); + // Les entrées compromises affichées sont reconstruites à partir des mots de + // passe retenus, jamais conservées telles quelles : si l'utilisateur vient + // de corriger un mot de passe signalé, il ne doit plus apparaître ici. + final breachedPw = _breachedPasswords; + _breached = breachedPw == null + ? null + : _all + .where( + (e) => + e.type == EntryType.password && + e.password.isNotEmpty && + breachedPw.contains(e.password), + ) + .toList(); + + _computeScore(); + } + + /// Score = santé MOYENNE des mots de passe du coffre. + /// + /// Voir `VaultAuditScore` pour le raisonnement : l'ancien calcul soustrayait + /// des points en valeur absolue et plafonnait, si bien qu'un coffre de six + /// entrées toutes faibles affichait « Bon ». + void _computeScore() { + final breachedPw = _breachedPasswords; + _score = VaultAuditScore.average([ + for (final e in _all) + if (e.type == EntryType.password && e.password.isNotEmpty) + breachedPw != null && breachedPw.contains(e.password) + ? VaultAuditScore.pointsCompromised + // Le repli est INATTEIGNABLE par construction : `_pointsBase` est + // rempli dans `_analyze` sous le filtre EXACTEMENT identique à + // celui de la ligne ci-dessus. Désaligner ces deux filtres + // rendrait des entrées silencieusement « saines ». + : (_pointsBase[e.id] ?? VaultAuditScore.pointsHealthy), + ]); + } + + /// Vrai dès qu'AU MOINS UN mot de passe du coffre n'a pas été confronté aux + /// fuites — jamais lancé, ou lancé avant l'ajout de cette entrée. + /// + /// Le score ignore alors le seul signal FACTUEL dont l'application dispose, + /// et il est annoncé comme partiel plutôt que présenté comme un verdict. + bool get _scorePartial { + // Pas de score, rien à compléter. Sans cette ligne, un coffre dont toutes + // les entrées ont un mot de passe VIDE affichait « — » assorti d'un + // avertissement de score partiel. + if (_score == null) return false; + final checked = _checkedPasswords; + if (checked == null) return true; + return _all.any( + (e) => + e.type == EntryType.password && + e.password.isNotEmpty && + !checked.contains(e.password), + ); } /// v2.5.0 (F4a + F14) — couleurs sémantiques alignées Material 3. @@ -129,16 +214,22 @@ class _AuditScreenState extends State { /// contraste limite < WCAG AA pour le texte blanc — corrigé. Color _scoreColor(BuildContext ctx) { final cs = Theme.of(ctx).colorScheme; - if (_score >= 90) return cs.tertiary; - if (_score >= 70) return Colors.amber.shade700; - if (_score >= 50) return Colors.deepOrange; + final s = _score; + // Coffre sans mot de passe : rien à qualifier, donc pas de couleur de + // verdict. Un vert « Excellent » sur un coffre vide serait un mensonge. + if (s == null) return cs.outline; + if (s >= 90) return cs.tertiary; + if (s >= 70) return Colors.amber.shade700; + if (s >= 50) return Colors.deepOrange; return cs.error; } String _scoreLabel(AppLocalizations t) { - if (_score >= 90) return t.auditScoreExcellent; - if (_score >= 70) return t.auditScoreGood; - if (_score >= 50) return t.auditScoreMedium; + final s = _score; + if (s == null) return t.auditScoreNone; + if (s >= 90) return t.auditScoreExcellent; + if (s >= 70) return t.auditScoreGood; + if (s >= 50) return t.auditScoreMedium; return t.auditScoreWeak; } @@ -147,9 +238,11 @@ class _AuditScreenState extends State { /// daltonien deutéranope/protanope confond. Icône complémentaire avec /// charge sémantique cohérente. IconData _scoreIcon() { - if (_score >= 90) return Icons.check_circle; - if (_score >= 70) return Icons.thumb_up_alt; - if (_score >= 50) return Icons.warning_amber_rounded; + final s = _score; + if (s == null) return Icons.help_outline; + if (s >= 90) return Icons.check_circle; + if (s >= 70) return Icons.thumb_up_alt; + if (s >= 50) return Icons.warning_amber_rounded; return Icons.error; } @@ -195,7 +288,7 @@ class _AuditScreenState extends State { width: 90, height: 90, child: CircularProgressIndicator( - value: _score / 100, + value: (_score ?? 0) / 100, strokeWidth: 8, backgroundColor: cs.surfaceContainerHighest, valueColor: AlwaysStoppedAnimation(scoreColor), @@ -205,20 +298,21 @@ class _AuditScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - '$_score', + _score?.toString() ?? '—', style: TextStyle( fontSize: 24, fontWeight: FontWeight.w700, color: scoreColor, ), ), - Text( - '/100', - style: TextStyle( - fontSize: 12, - color: cs.onSurfaceVariant, + if (_score != null) + Text( + '/100', + style: TextStyle( + fontSize: 12, + color: cs.onSurfaceVariant, + ), ), - ), ], ), ], @@ -230,7 +324,9 @@ class _AuditScreenState extends State { // U4 v2.4.4 — Semantics group annonçant le score et // sa qualification (TalkBack lit "Score 85 sur 100, // Bon" au lieu de juste "85" hors contexte). - label: '${t.auditScoreLabel} $_score / 100', + label: _score == null + ? t.auditScoreLabel + : '${t.auditScoreLabel} $_score / 100', value: _scoreLabel(t), container: true, child: Column( @@ -265,7 +361,9 @@ class _AuditScreenState extends State { ), const SizedBox(height: 4), Text( - _score == 100 + _score == null + ? t.auditScoreNoneHint + : _score == 100 ? t.auditScorePerfect : t.auditScoreImprovements, style: TextStyle( @@ -273,6 +371,32 @@ class _AuditScreenState extends State { color: cs.onSurfaceVariant, ), ), + // Le score ignore les fuites tant que la + // vérification n'a pas tourné : c'est le seul signal + // FACTUEL de l'écran, et le taire donnerait un + // verdict trop favorable sans le dire. + if (_scorePartial) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.info_outline, + size: 14, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + t.auditScorePartial, + style: TextStyle( + fontSize: 12, + color: cs.onSurfaceVariant, + ), + ), + ), + ], + ), + ], ], ), ), @@ -389,10 +513,17 @@ class _AuditScreenState extends State { onTap: _refreshFromDetail, emptyText: t.auditIssueNo2faEmpty, ), + // L'ancienneté est INFORMATIVE et ne pèse plus sur le score. + // + // Le NIST (SP 800-63B) recommande explicitement de ne pas imposer de + // rotation périodique et de ne changer qu'en cas de compromission + // avérée. La section est conservée — savoir ce qu'on n'a pas revu + // depuis un an a une valeur — mais elle passe en ton neutre : ce + // n'est pas un défaut, et la présenter en orange en faisait un. _IssueSection( title: t.auditIssueOldTitle, description: t.auditIssueOldDesc, - color: Colors.amber.shade700, + color: Theme.of(context).colorScheme.outline, icon: Icons.schedule_outlined, entries: _old, onTap: _refreshFromDetail, @@ -433,6 +564,7 @@ class _AuditScreenState extends State { ); final breached = []; + final breachedPasswords = {}; bool networkOk = true; for (final e in candidates) { final n = results[e.password] ?? -1; @@ -440,7 +572,10 @@ class _AuditScreenState extends State { networkOk = false; break; } - if (n > 0) breached.add(e); + if (n > 0) { + breached.add(e); + breachedPasswords.add(e.password); + } } if (!mounted) return; @@ -455,6 +590,15 @@ class _AuditScreenState extends State { setState(() { _checkingBreach = false; _breached = breached; + // Le résultat entre dans le score. Avant, il ne servait qu'à peupler une + // liste : un mot de passe dont on a la PREUVE qu'il figure dans une fuite + // publique ne coûtait rien, tandis que l'ancienneté — le critère le plus + // contestable — coûtait jusqu'à vingt points. + _breachedPasswords = breachedPasswords; + // La COUVERTURE est enregistrée séparément du résultat : un mot de passe + // ajouté après cette vérification doit remettre le score en « partiel ». + _checkedPasswords = {for (final e in candidates) e.password}; + _computeScore(); }); } } diff --git a/lib/services/vault_audit_score.dart b/lib/services/vault_audit_score.dart new file mode 100644 index 0000000..d746471 --- /dev/null +++ b/lib/services/vault_audit_score.dart @@ -0,0 +1,105 @@ +/// Calcul du score de l'audit de coffre — fonction PURE, testable. +/// +/// ## Pourquoi ce fichier existe +/// +/// Le score était calculé en ligne dans l'écran, par soustractions absolues : +/// +/// ```dart +/// s -= _weak.length.clamp(0, 6) * 5; // -30 au maximum +/// s -= _duplicates.length.clamp(0, 6) * 5; // -30 au maximum +/// s -= _old.length.clamp(0, 4) * 5; // -20 au maximum +/// s -= _missing2fa.length.clamp(0, 4) * 5; // -20 au maximum +/// ``` +/// +/// Quatre défauts, tous corrigés ici : +/// +/// 1. **Il ne tenait aucun compte de la taille du coffre.** Au-delà de six +/// mots de passe faibles la pénalité s'arrêtait. Un coffre de six entrées +/// dont les six étaient faibles affichait donc **70/100, « Bon »** — et un +/// coffre de deux cents entrées avec six faibles affichait exactement la +/// même chose. Le score rassurait précisément là où il ne fallait pas. +/// +/// 2. **Le seul signal FACTUEL ne comptait pas.** Le résultat de la +/// vérification HaveIBeenPwned n'entrait pas dans le calcul : un mot de +/// passe dont on a la preuve qu'il figure dans une fuite publique valait +/// zéro point de pénalité, pendant que l'ancienneté — le signal le plus +/// contestable — en coûtait jusqu'à vingt. +/// +/// 3. **L'ancienneté pénalisait, à rebours de l'état de l'art.** Le NIST +/// (SP 800-63B) recommande explicitement de NE PAS imposer de rotation +/// périodique et de ne changer qu'en cas de compromission avérée. Punir un +/// mot de passe fort, unique et non compromis parce qu'il a un an pousse à +/// l'incrément (`MotDePasse1` → `MotDePasse2`), qui est plus faible. Elle +/// est désormais informative et ne touche plus au score. +/// +/// 4. **Les défauts se cumulaient sur une même entrée.** Un mot de passe à la +/// fois faible ET réutilisé était compté deux fois. Ici une entrée vaut son +/// défaut le PLUS GRAVE, une seule fois. +/// +/// ## Le modèle retenu +/// +/// Le score est la **santé moyenne des mots de passe du coffre**. Chaque +/// entrée vaut un nombre de points selon son pire défaut, et le score est la +/// moyenne. Il est donc proportionnel par construction : une entrée fautive +/// parmi deux cents pèse ce qu'elle doit peser, et six sur six écrasent le +/// score comme elles le doivent. +library; + +abstract final class VaultAuditScore { + /// Catégories dont on attend un second facteur. + /// + /// Ces chaînes sont les valeurs CANONIQUES stockées dans le coffre, pas des + /// libellés traduits — cf. `models/category.dart`, qui conserve le français + /// en base pour ne pas casser les coffres existants. Les comparer à des + /// chaînes traduites ferait silencieusement échouer le contrôle hors du + /// français. + /// + /// « Réseaux sociaux » a été ajoutée : la prise de contrôle d'un compte + /// social sert de rebond vers les autres services (réinitialisations, + /// authentification déléguée), au même titre qu'une messagerie. + static const sensitiveCategories = {'Banque', 'Email', 'Réseaux sociaux'}; + + /// Barème par entrée. Une entrée ne compte QUE pour son pire défaut. + /// + /// Les valeurs ne sont pas des pourcentages de gravité mais des points de + /// santé : `compromised` vaut 0 parce qu'un mot de passe publiquement fuité + /// n'offre plus aucune protection, quelle que soit sa complexité. + static const int pointsCompromised = 0; + static const int pointsWeak = 25; + static const int pointsDuplicate = 55; + static const int pointsNo2fa = 85; + static const int pointsHealthy = 100; + + /// Points d'une entrée, par gravité décroissante. + /// + /// L'ordre des tests EST le barème : il fixe lequel des défauts l'emporte + /// quand une entrée en cumule plusieurs. + static int pointsFor({ + required bool compromised, + required bool weak, + required bool duplicate, + required bool sensitiveWithout2fa, + }) { + if (compromised) return pointsCompromised; + if (weak) return pointsWeak; + if (duplicate) return pointsDuplicate; + if (sensitiveWithout2fa) return pointsNo2fa; + return pointsHealthy; + } + + /// Moyenne des points sur la population de mots de passe. + /// + /// Retourne `null` quand cette population est VIDE — un coffre sans mot de + /// passe n'a pas de score. L'ancienne formule y répondait `100`, + /// « Excellent », ce qui revenait à féliciter un coffre vide. + static int? average(Iterable pointsPerEntry) { + var total = 0; + var n = 0; + for (final p in pointsPerEntry) { + total += p; + n++; + } + if (n == 0) return null; + return (total / n).round().clamp(0, 100); + } +} diff --git a/test/vault_audit_score_test.dart b/test/vault_audit_score_test.dart new file mode 100644 index 0000000..489ebc2 --- /dev/null +++ b/test/vault_audit_score_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pass_tech/services/vault_audit_score.dart'; + +/// Le score de l'audit etait calcule en ligne dans l'ecran, par soustractions +/// absolues plafonnees. Il n'etait couvert par aucun test, et il etait FAUX : +/// un coffre de six entrees toutes faibles affichait « 70 / Bon ». +/// +/// Ces tests fixent le contrat du nouveau modele — la sante MOYENNE des mots de +/// passe — et verrouillent en particulier les deux proprietes que l'ancien +/// calcul violait : la proportionnalite, et l'absence de double comptage. +void main() { + int? scoreOf(List points) => VaultAuditScore.average(points); + + int pts({ + bool compromised = false, + bool weak = false, + bool duplicate = false, + bool no2fa = false, + }) => VaultAuditScore.pointsFor( + compromised: compromised, + weak: weak, + duplicate: duplicate, + sensitiveWithout2fa: no2fa, + ); + + group('bareme par entree', () { + test('une entree saine vaut le maximum', () { + expect(pts(), 100); + }); + + test('la compromission ecrase tout le reste', () { + // Un mot de passe publiquement fuite n'offre plus aucune protection, + // quelle que soit sa complexite ou son unicite. + expect(pts(compromised: true), 0); + expect( + pts(compromised: true, weak: true, duplicate: true, no2fa: true), + 0, + ); + }); + + test('une entree ne compte QUE pour son pire defaut', () { + // L'ancien calcul soustrayait une penalite par categorie : un mot de + // passe a la fois faible ET reutilise etait compte deux fois. + expect(pts(weak: true, duplicate: true), pts(weak: true)); + expect(pts(duplicate: true, no2fa: true), pts(duplicate: true)); + }); + + test('gravite strictement decroissante', () { + expect(pts(compromised: true), lessThan(pts(weak: true))); + expect(pts(weak: true), lessThan(pts(duplicate: true))); + expect(pts(duplicate: true), lessThan(pts(no2fa: true))); + expect(pts(no2fa: true), lessThan(pts())); + }); + }); + + group('LE defaut historique : le score ignorait la taille du coffre', () { + test('six entrees sur six faibles ne peut pas etre « Bon »', () { + // Cas exact du bug : l'ancienne formule plafonnait la penalite a -30 des + // le sixieme mot de passe faible et affichait 70, soit « Bon », sur un + // coffre dont AUCUN mot de passe n'etait correct. + final score = scoreOf(List.filled(6, pts(weak: true)))!; + expect(score, lessThan(50), reason: 'doit tomber dans « Faible »'); + }); + + test('six faibles parmi deux cents ne bouge presque pas le score', () { + final score = scoreOf([ + ...List.filled(6, pts(weak: true)), + ...List.filled(194, pts()), + ])!; + expect(score, greaterThanOrEqualTo(90)); + }); + + test('meme nombre de fautifs, tailles differentes, scores differents', () { + // C'est exactement ce que l'ancienne formule rendait IDENTIQUE. + final petit = scoreOf([ + ...List.filled(6, pts(weak: true)), + ...List.filled(4, pts()), + ])!; + final grand = scoreOf([ + ...List.filled(6, pts(weak: true)), + ...List.filled(194, pts()), + ])!; + expect(petit, lessThan(grand)); + }); + + test('le score decroit quand la proportion de fautifs croit', () { + var precedent = 101; + for (final fautifs in [0, 25, 50, 75, 100]) { + final s = scoreOf([ + ...List.filled(fautifs, pts(weak: true)), + ...List.filled(100 - fautifs, pts()), + ])!; + expect(s, lessThan(precedent), reason: '$fautifs fautifs sur 100'); + precedent = s; + } + }); + }); + + group('les fuites entrent dans le score', () { + test('un coffre entierement compromis tombe a zero', () { + expect(scoreOf(List.filled(10, pts(compromised: true))), 0); + }); + + test('une compromission pese plus qu une faiblesse', () { + final avecFuite = scoreOf([ + pts(compromised: true), + ...List.filled(9, pts()), + ])!; + final avecFaible = scoreOf([pts(weak: true), ...List.filled(9, pts())])!; + expect(avecFuite, lessThan(avecFaible)); + }); + }); + + group('bornes', () { + test('coffre sans mot de passe : pas de score', () { + // L'ancienne formule repondait 100 « Excellent » — feliciter un coffre + // vide. `null` force l'ecran a afficher autre chose qu'un verdict. + expect(scoreOf(const []), isNull); + }); + + test('coffre entierement sain : 100', () { + expect(scoreOf(List.filled(50, pts())), 100); + }); + + test('le resultat reste dans 0..100', () { + for (final n in [1, 2, 3, 7, 33, 100]) { + for (final p in [0, 25, 55, 85, 100]) { + final s = scoreOf(List.filled(n, p))!; + expect(s, inInclusiveRange(0, 100)); + } + } + }); + + test('une seule entree rend exactement son bareme', () { + expect(scoreOf([pts(weak: true)]), VaultAuditScore.pointsWeak); + expect(scoreOf([pts(duplicate: true)]), VaultAuditScore.pointsDuplicate); + expect(scoreOf([pts(no2fa: true)]), VaultAuditScore.pointsNo2fa); + }); + }); + + group('categories sensibles', () { + test('valeurs CANONIQUES du coffre, pas des libelles traduits', () { + // `models/category.dart` conserve le francais en base pour ne pas casser + // les coffres existants. Comparer a des libelles traduits ferait + // silencieusement echouer le controle hors du francais. + expect(VaultAuditScore.sensitiveCategories, contains('Banque')); + expect(VaultAuditScore.sensitiveCategories, contains('Email')); + expect(VaultAuditScore.sensitiveCategories, isNot(contains('Bank'))); + }); + + test('les reseaux sociaux comptent comme sensibles', () { + // Une prise de controle de compte social sert de rebond vers les autres + // services (reinitialisations, authentification deleguee). + expect(VaultAuditScore.sensitiveCategories, contains('Réseaux sociaux')); + }); + }); +} From 499855290fe80ad34a2e80eaf89bbf436a303aa1 Mon Sep 17 00:00:00 2001 From: Pat Date: Tue, 4 Aug 2026 15:54:05 +0200 Subject: [PATCH 34/34] chore: v2.6.0 (versionCode 39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mineure et non corrective : le comportement visible change. - Le score de l audit est recalcule sur un autre modele. Il baissera chez les utilisateurs de petits coffres, non par regression mais parce qu il ne plafonne plus la penalite. - La politique de mot de passe change : 12 caracteres, symboles et chiffres ne sont plus exiges. - Google est retire de l application : plus aucune bibliotheque ni service Google embarque. Pass Tech lit sa version via PackageInfo : il n existe AUCUNE constante statique a bumper, contrairement a PDF Tech et Notes Tech. Verifie — les occurrences de 2.5.4 dans le code sont toutes des marqueurs SEC en commentaire. Changelogs fastlane FR 470 et EN 429 caracteres, sous le plafond F-Droid de 500. Co-Authored-By: Claude Opus 5 --- fastlane/metadata/android/en-US/changelogs/39.txt | 7 +++++++ fastlane/metadata/android/fr-FR/changelogs/39.txt | 7 +++++++ pubspec.yaml | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/39.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/39.txt diff --git a/fastlane/metadata/android/en-US/changelogs/39.txt b/fastlane/metadata/android/en-US/changelogs/39.txt new file mode 100644 index 0000000..e5294a1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/39.txt @@ -0,0 +1,7 @@ +Major privacy and reliability release. + +• Google removed from the app: no Google library or service is bundled any more. +• Security audit fixed: the score was misleading on small vaults and ignored detected breaches. +• Master password: 12 characters, no mandatory symbol or digit. +• Panic mode: warning before enabling, biometrics will be turned off. +• Cancelling the fingerprint prompt no longer traps you on the unlock screen. diff --git a/fastlane/metadata/android/fr-FR/changelogs/39.txt b/fastlane/metadata/android/fr-FR/changelogs/39.txt new file mode 100644 index 0000000..aaec35c --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/39.txt @@ -0,0 +1,7 @@ +Version majeure de confidentialité et de fiabilité. + +• Google retiré de l'application : plus aucune bibliothèque ni service Google embarqué. +• Audit de sécurité corrigé : le score était trompeur sur les petits coffres et ignorait les fuites détectées. +• Mot de passe maître : 12 caractères, sans symbole ni chiffre obligatoire. +• Panique : avertissement avant activation, la biométrie sera désactivée. +• Annuler l'empreinte n'enferme plus sur l'écran de déverrouillage. diff --git a/pubspec.yaml b/pubspec.yaml index 6a4de0b..004ceec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pass_tech description: "Gestionnaire de mots de passe 100% local et sécurisé" publish_to: 'none' -version: 2.5.4+38 +version: 2.6.0+39 environment: sdk: ^3.11.5