fix(android): report the biometric failure instead of destroying the cache
The launch-time auto-open received boUnavailable and wiped the cache, seconds after the write path had proved the device could authenticate. The diagnosis was wrong and the reaction irreversible. An automatic action now never clears the vault: it logs the cause and leaves the user on the unlock screen. Only an explicit tap on Open, followed by a clean refusal from the system, may clear it. PMA.Biometric keeps the reason in LastBiometricError, and a Java-layer exception now maps to boFailed rather than boUnavailable - an exception says nothing about the device. The log memo also wraps: it was cutting off exactly the end of the message, which is where the cause is named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<TBioOutcome
|
||||
|
||||
implementation
|
||||
|
||||
// La clause uses de l'implementation doit venir AVANT toute declaration :
|
||||
// placee apres, Delphi ne s'en plaint qu'a la derniere ligne du fichier.
|
||||
{$IF DEFINED(ANDROID)}
|
||||
uses
|
||||
FMX.BiometricAuth, FMX.Platform;
|
||||
{$ENDIF}
|
||||
|
||||
var
|
||||
FLastError: string = '';
|
||||
|
||||
function LastBiometricError: string;
|
||||
begin
|
||||
Result := FLastError;
|
||||
end;
|
||||
|
||||
{$IF DEFINED(ANDROID)}
|
||||
const
|
||||
// DeviceCredential inclus : un telephone protege par un schema ou un code,
|
||||
// sans empreinte enrolee, doit rester utilisable. C'est le cas courant.
|
||||
@@ -74,11 +100,23 @@ begin
|
||||
// de repondre False. Sans cette garde, un manifeste incomplet ne se traduit
|
||||
// pas par « pas de biometrie » mais par une boite d'exception en pleine
|
||||
// ouverture du coffre.
|
||||
FLastError := '';
|
||||
try
|
||||
Result := AcquireService(LSvc) and LSvc.CanAuthenticate;
|
||||
if not AcquireService(LSvc) then
|
||||
begin
|
||||
FLastError := 'service biometrique absent de la plateforme';
|
||||
Exit(False);
|
||||
end;
|
||||
Result := LSvc.CanAuthenticate;
|
||||
if not Result then
|
||||
FLastError := 'CanAuthenticate: aucune protection utilisable';
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
FLastError := E.ClassName + ': ' + E.Message;
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
begin
|
||||
@@ -95,21 +133,29 @@ begin
|
||||
if not Assigned(AThen) then Exit;
|
||||
|
||||
// AcquireService pose les forces ; CanAuthenticate n'a de sens qu'apres.
|
||||
FLastError := '';
|
||||
if not AcquireService(LSvc) then
|
||||
begin
|
||||
AThen(boUnavailable);
|
||||
FLastError := 'service biometrique absent de la plateforme';
|
||||
AThen(boFailed); // pas boUnavailable : on ne sait rien de l'appareil
|
||||
Exit;
|
||||
end;
|
||||
try
|
||||
if not LSvc.CanAuthenticate then
|
||||
begin
|
||||
// Reponse NETTE du systeme : l'appareil n'a rien pour authentifier.
|
||||
FLastError := 'CanAuthenticate: aucune protection utilisable';
|
||||
AThen(boUnavailable);
|
||||
Exit;
|
||||
end;
|
||||
except
|
||||
AThen(boUnavailable);
|
||||
on E: Exception do
|
||||
begin
|
||||
FLastError := E.ClassName + ': ' + E.Message;
|
||||
AThen(boFailed);
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
LProps := Default(TBiometricPromptProperties);
|
||||
LProps.Title := 'Deverrouiller le coffre';
|
||||
@@ -134,7 +180,11 @@ begin
|
||||
AThen(boFailed);
|
||||
end);
|
||||
except
|
||||
AThen(boUnavailable);
|
||||
on E: Exception do
|
||||
begin
|
||||
FLastError := E.ClassName + ': ' + E.Message;
|
||||
AThen(boFailed);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
{$ELSE}
|
||||
|
||||
+32
-4
@@ -132,6 +132,9 @@ type
|
||||
procedure OnTotpTick(Sender: TObject);
|
||||
|
||||
procedure DoOpenLocal(Sender: TObject);
|
||||
/// AAuto : declenche par l'ouverture automatique au lancement, pas par
|
||||
/// l'utilisateur. Une action automatique ne detruit jamais le cache.
|
||||
procedure OpenVault(AAuto: Boolean);
|
||||
// Separee de DoOpenLocal : une methode anonyme ne peut pas appeler une
|
||||
// procedure imbriquee (E2555), et le rappel de la biometrie en est une.
|
||||
procedure OpenLocalNow;
|
||||
@@ -226,7 +229,7 @@ begin
|
||||
// l'invite biometrique s'afficherait sinon avant la premiere peinture de la
|
||||
// fenetre, sur un fond vide.
|
||||
if HasVault then
|
||||
TThread.ForceQueue(nil, procedure begin DoOpenLocal(nil); end);
|
||||
TThread.ForceQueue(nil, procedure begin OpenVault(True); end);
|
||||
end;
|
||||
|
||||
destructor TForm8.Destroy;
|
||||
@@ -277,6 +280,9 @@ begin
|
||||
FLog.Height := 150;
|
||||
FLog.Margins.Rect := RectF(8, 8, 8, 8);
|
||||
FLog.ReadOnly := True;
|
||||
// Sans repli, un message d'exception est coupe net au bord du memo - et
|
||||
// c'est toujours la fin qui nomme la cause.
|
||||
FLog.WordWrap := True;
|
||||
FLog.TextSettings.Font.Family := 'monospace';
|
||||
FLog.TextSettings.Font.Size := 11;
|
||||
|
||||
@@ -727,6 +733,11 @@ begin
|
||||
end;
|
||||
|
||||
procedure TForm8.DoOpenLocal(Sender: TObject);
|
||||
begin
|
||||
OpenVault(False); // geste explicite de l'utilisateur
|
||||
end;
|
||||
|
||||
procedure TForm8.OpenVault(AAuto: Boolean);
|
||||
begin
|
||||
if FBusy then Exit;
|
||||
// L'authentification affiche un dialogue systeme : elle doit rester sur le
|
||||
@@ -737,7 +748,7 @@ begin
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Status('Authentification...');
|
||||
if not AAuto then Status('Authentification...');
|
||||
AuthenticateThen('Acceder au coffre',
|
||||
procedure(AOutcome: TBioOutcome)
|
||||
begin
|
||||
@@ -745,14 +756,31 @@ begin
|
||||
boOK: OpenLocalNow;
|
||||
boCancelled: Status('Annule');
|
||||
boUnavailable:
|
||||
// Reponse nette du systeme : plus rien pour authentifier sur cet
|
||||
// appareil, donc la cle est perdue et le cache avec elle.
|
||||
//
|
||||
// UNE ACTION AUTOMATIQUE N'EFFACE JAMAIS LE CACHE. Au lancement on
|
||||
// se contente de le dire : si le diagnostic est faux - service pas
|
||||
// encore pret, etat transitoire - effacer imposerait un
|
||||
// retelechargement et quatre secondes de derivation pour rien, et
|
||||
// masquerait la vraie cause. L'utilisateur tranche en reessayant.
|
||||
if AAuto then
|
||||
begin
|
||||
Log('Authentification indisponible au lancement - cache conserve');
|
||||
Log(' ' + LastBiometricError);
|
||||
Status('Authentification indisponible - reessayer');
|
||||
end
|
||||
else
|
||||
begin
|
||||
// La protection a ete retiree de l'appareil depuis l'ecriture du
|
||||
// cache : celui-ci est devenu illisible pour toujours.
|
||||
Log('Plus aucune protection sur l''appareil - cache efface');
|
||||
Log(' ' + LastBiometricError);
|
||||
ClearVault;
|
||||
ShowUnlock;
|
||||
end;
|
||||
else
|
||||
// boFailed : la couche Java a leve quelque chose. Cela ne dit rien de
|
||||
// l'appareil, donc on ne touche a rien - on rapporte.
|
||||
Log('Echec de l''authentification : ' + LastBiometricError);
|
||||
Status('Echec de l''authentification');
|
||||
end;
|
||||
end);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.\Android64\Debug\pmserverApk\classes\pmserverApk.classes
|
||||
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v31\splash_vector.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v26\ic_launcher.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-hdpi\ic_launcher.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-night-anydpi-v21\splash_vector.xml
|
||||
.\Android64\Debug\pmserverApk\AndroidManifest.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-mdpi\ic_launcher.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-xxhdpi\ic_launcher.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-xxxhdpi\ic_launcher.png
|
||||
.\Android64\Debug\pmserverApk\res\values-night-v21\colors.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-xhdpi\ic_notification.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-large\splash_image.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable\ic_launcher_monochrome.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v21\splash_image_def.xml
|
||||
.\Android64\Debug\pmserverApk\library\lib\armeabi\libpmserverApk.so
|
||||
.\Android64\Debug\pmserverApk\res\values-v31\styles.xml
|
||||
.\Android64\Debug\pmserverApk\res\values\colors.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable\splash_image_def.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-hdpi\ic_notification.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-xxxhdpi\ic_notification.png
|
||||
.\Android64\Debug\pmserverApk\library\lib\mips\libpmserverApk.so
|
||||
.\Android64\Debug\pmserverApk\res\drawable-xhdpi\ic_launcher.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable\splash_vector.xml
|
||||
.\Android64\Debug\pmserverApk\library\lib\armeabi-v7a\libpmserverApk.so
|
||||
.\Android64\Debug\pmserverApk\res\drawable-mdpi\ic_notification.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-ldpi\ic_launcher.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-xlarge\splash_image.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-night-anydpi-v31\splash_vector.xml
|
||||
.\Android64\Debug\pmserverApk\res\values\styles.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-small\splash_image.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v24\ic_notification.xml
|
||||
.\Android64\Debug\pmserverApk\res\values\strings.xml
|
||||
.\Android64\Debug\pmserverApk\res\values-v21\styles.xml
|
||||
.\Android64\Debug\pmserverApk\library\lib\arm64-v8a\libpmserverApk.so
|
||||
.\Android64\Debug\pmserverApk\res\drawable-xxhdpi\ic_notification.png
|
||||
.\Android64\Debug\pmserverApk\res\drawable\ic_launcher_foreground.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable\ic_launcher_background.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v33\ic_launcher.xml
|
||||
.\Android64\Debug\pmserverApk\res\drawable-normal\splash_image.png
|
||||
Reference in New Issue
Block a user