Skip to content

sec: audit complet 2026-08-03/04 — 46 correctifs, retrait de Google ML Kit - #14

Merged
gitubpatrice merged 34 commits into
masterfrom
fix/audit-2026-08-03
Aug 4, 2026
Merged

sec: audit complet 2026-08-03/04 — 46 correctifs, retrait de Google ML Kit#14
gitubpatrice merged 34 commits into
masterfrom
fix/audit-2026-08-03

Conversation

@gitubpatrice

Copy link
Copy Markdown
Owner

Audit de sécurité complet des 2026-08-03 / 04. 46 correctifs, aucun changement de version — la 2.5.4 reste la version publiée.

Origine des findings

Source Réelles
Audit manuel (lecture intégrale, 46 fichiers Dart + 4 Kotlin) 16
Gemini 3.1 Pro — 2 passes 18
Gemini 3.6 Flash 3
Tests sur appareil 9

Sur 27 findings rapportées par les modèles, 21 se sont révélées réelles après relecture du code ; 6 ont été écartées, dont une de sévérité « haute » dont la prémisse n'existait pas dans l'application.

Les plus importantes

  • Corruption définitive du coffre si le changement de mot de passe maître échouait en cours d'écriture : ni l'ancien ni le nouveau mot de passe n'ouvraient plus rien. Déclenchable par un simple disque plein.
  • Le verrouillage automatique ne se déclenchait pas après une veille profonde. Stopwatch s'appuie sur CLOCK_MONOTONIC, qui ne compte pas le sommeil de l'appareil : téléphone posé deux heures, coffre toujours ouvert au retour.
  • Sauvegarde .ptbak potentiellement corrompue : le fichier partagé était déchiqueté avant que la cible ait fini de le lire.
  • Paramètres Argon2id écrits mais jamais relus — un relèvement des constantes aurait rendu tous les coffres, instantanés et sauvegardes indéchiffrables, en silence.
  • Déni plausible cassé au repos : les deux fichiers de coffre redivergeaient en taille de façon permanente.

Google entièrement retiré

mobile_scanner supprimé, et avec lui ML Kit, Play Services et le transport de télémétrie datatransport.

Permissions de l'APK signé, vérifiées sur le manifeste fusionné :

avant : INTERNET · USE_BIOMETRIC · USE_FINGERPRINT · CAMERA · ACCESS_NETWORK_STATE
après : INTERNET · USE_BIOMETRIC · USE_FINGERPRINT

ACCESS_NETWORK_STATE n'était déclarée nulle part dans le dépôt — elle arrivait par une dépendance transitive. Le scan de QR code est remplacé par le collage d'une URI otpauth://, dont le secret est extrait automatiquement.

Garde-fous permanents ajoutés

  • android/expected-permissions.txt + .github/workflows/promesses.yml : la liste des permissions est vérifiée sur l'APK construit à chaque commit, avec un contrôle Exodus Privacy.
  • ci.yml durci : le flutter test || echo "No tests yet" faisait passer le job au vert même quand les tests échouaient.
  • THREAT_MODEL.md : premier modèle de menace écrit, limites comprises.
  • Règle de mot de passe unique partagée par les 5 points d'entrée — le contrôle d'entropie ne gardait que la création.

Ce que seuls les tests appareil ont trouvé

Neuf défauts qu'aucune analyse statique n'a vus, tous de la même famille : le mécanisme est correct, c'est ce qu'il communique qui ne l'est pas. Sortie du camouflage inatteignable sans le coffre, biométrie supprimée sans avertissement, annulation présentée comme un échec, boucle d'invite au lancement.

Vérifications

flutter analyze : 0 issue · 153 tests (+27) · 11 validations sur appareil (S24 FE).

Deux limites assumées et documentées : le camouflage partiel dans Réglages Android (android:label n'est pas modifiable à l'exécution) et le blocage Knox du collage inter-applications sur une fenêtre FLAG_SECURE.

Une chose non vérifiable sur appareil : la fermeture de la vue héritier au retour d'arrière-plan, qui exigerait 90 jours d'inactivité. Elle repose sur la relecture du code seule.

🤖 Generated with Claude Code

Pat and others added 30 commits August 3, 2026 21:03
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- `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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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
  `<title>mabanque.fr</title>` 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) <noreply@anthropic.com>
…egarde

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…plan

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) <noreply@anthropic.com>
…de mot de passe

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…efface

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… filtres

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <fichier> <apk>`, 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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
CRITIQUE — le cache meta manquait a l'instantane de _createSlot.

_saveVaultV4 termine par _cachedSalt/_cachedWrappedDek/_cachedWrapNonce/
_cachedKdfParams = <metadonnees du slot ecrit>. 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 <noreply@anthropic.com>
Pat and others added 4 commits August 4, 2026 15:22
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 <noreply@anthropic.com>
…ogue

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@gitubpatrice
gitubpatrice merged commit 753b5e3 into master Aug 4, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant