diff --git a/android-client/PLAN.md b/android-client/PLAN.md index 9642851..81ff07b 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -57,6 +57,43 @@ retéléchargé, pas rester illisible. C'est l'autre branche de la même garde. --- +## 1 bis. La décision qui bloque le cache (constatée le 2026-08-15) + +Le cache ne s'écrit presque jamais. Journal sur appareil : + +``` +dechiffre en 4446 ms +cache non ecrit : EJNIException - android.security.key… +``` + +**Cause.** Une clé Keystore créée avec `setUserAuthenticationRequired` + +`setUserAuthenticationValidityDurationSeconds` exige une authentification +récente **pour chiffrer autant que pour déchiffrer**. Or l'écriture suit un +téléchargement silencieux, sans invite : passé la fenêtre de 30 s depuis le +dernier déverrouillage, `Cipher.init(ENCRYPT_MODE)` lève. La toute première +écriture avait réussi seulement parce que le téléphone venait d'être +déverrouillé — d'où un test qui passe puis ne passe plus. + +Conséquence : l'application fonctionne, mais redérive 4,4 s à chaque +lancement. Le cache, qui est toute la raison d'être de `PMA.Store`, ne sert +jamais. + +**Trois issues, par ordre de justesse :** + +1. **Paire de clés asymétrique** — c'est le motif documenté par Android pour + « écrire à tout moment, lire sous authentification ». La partie publique + chiffre sans invite, la partie privée (protégée) déchiffre. Comme RSA ne + chiffre pas 16 Ko, il faut un chiffrement hybride : une clé AES aléatoire + par écriture, enveloppée par la clé publique. Plus de fenêtre de validité, + plus de course contre le temps. ~80 lignes de JNI. +2. **Authentifier avant d'écrire** — une invite à chaque synchronisation de + fond, ce qui détruit précisément le silence recherché. +3. **Allonger la fenêtre** — atténue sans corriger, et affaiblit la garantie + proportionnellement. + +Retenir la 1. Les deux autres échangent une propriété de sécurité contre du +confort, ce qui est le mauvais sens pour un gestionnaire de mots de passe. + ## 2. Durcissement — petit, à faire avant tout usage réel Par ordre de gravité, pas de taille. diff --git a/android-client/PMA.Biometric.pas b/android-client/PMA.Biometric.pas index d417066..0ff3846 100644 --- a/android-client/PMA.Biometric.pas +++ b/android-client/PMA.Biometric.pas @@ -22,12 +22,26 @@ uses System.SysUtils; type + /// boUnavailable : l'appareil ne PEUT PAS authentifier - aucune empreinte, + /// aucun code. La cle sous authentification est alors definitivement + /// inutilisable, et le cache qu'elle protege aussi. + /// boFailed : la tentative a echoue pour une autre raison, y compris une + /// exception de la couche Java. Ne dit RIEN sur l'appareil, donc ne + /// justifie jamais de detruire quoi que ce soit. TBioOutcome = (boOK, boUnavailable, boFailed, boCancelled); /// L'appareil peut-il authentifier (empreinte, visage, ou code de /// verrouillage) ? False signifie qu'aucune protection n'est configuree. function BiometricAvailable: Boolean; +/// Pourquoi la derniere tentative a echoue. Vide apres un succes. +/// +/// Existe parce qu'un echec avale sinon sa propre cause : un TBioOutcome ne +/// distingue pas « l'appareil n'a aucune protection » de « la couche Java a +/// leve quelque chose », et l'appelant prend des decisions opposees dans les +/// deux cas - dont celle d'effacer le cache. +function LastBiometricError: string; + /// Demande l'authentification puis appelle AThen. Non bloquant : le resultat /// arrive par le rappel, sur le thread principal. /// Si l'appareil ne sait pas authentifier, AThen est appele avec boUnavailable @@ -37,10 +51,22 @@ procedure AuthenticateThen(const APrompt: string; const AThen: TProc