fix(android): USE_BIOMETRIC, and never lock the cache behind a door we cannot open

Opening the vault threw java.lang.SecurityException: USE_BIOMETRIC is not in
Delphi default permission list, and CanAuthenticate raises instead of
answering false. So the fault showed up neither at compile time nor at
startup, but on the first unlock - after the cache had already been written
under an auth-protected key.

The permission is the shallow half. The deep half is that SaveVault was
called with a hard-coded True: we asked for a key guarded by a prompt that
could not be shown, producing a file that was unreadable forever. It now
receives BiometricAvailable, evaluated on the main thread before the worker
starts.

BiometricAvailable and AuthenticateThen also swallow what comes up from the
Java layer and map it to boUnavailable. The caller has a path for every
TBioOutcome and none for an exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 12:09:29 +01:00
parent 6d31bf83d2
commit 4258e9b559
4 changed files with 77 additions and 21 deletions
+7
View File
@@ -8,6 +8,13 @@
android:installLocation="%installLocation%"> android:installLocation="%installLocation%">
<uses-sdk android:minSdkVersion="%minSdkVersion%" android:targetSdkVersion="%targetSdkVersion%" /> <uses-sdk android:minSdkVersion="%minSdkVersion%" android:targetSdkVersion="%targetSdkVersion%" />
<%uses-permission%> <%uses-permission%>
<!-- Absente de la liste par defaut de Delphi, et sans elle BiometricPrompt
leve java.lang.SecurityException a la premiere invite - pas au
demarrage, donc le defaut ne se voit qu'en essayant d'ouvrir le coffre.
USE_FINGERPRINT couvre les versions anterieures a Android 9, ou
BiometricPrompt n'existe pas encore. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-feature android:glEsVersion="0x00020000" android:required="true"/> <uses-feature android:glEsVersion="0x00020000" android:required="true"/>
<queries> <queries>
<%queries-child-elements%> <%queries-child-elements%>
+37 -16
View File
@@ -47,8 +47,17 @@ function BiometricAvailable: Boolean;
var var
LSvc: IFMXBiometricAuthService; LSvc: IFMXBiometricAuthService;
begin begin
Result := TPlatformServices.Current.SupportsPlatformService( // try/except et non un simple test : CanAuthenticate LEVE une
IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate; // java.lang.SecurityException si USE_BIOMETRIC manque au manifeste, au lieu
// 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.
try
Result := TPlatformServices.Current.SupportsPlatformService(
IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate;
except
Result := False;
end;
end; end;
{$ELSE} {$ELSE}
begin begin
@@ -64,8 +73,13 @@ var
begin begin
if not Assigned(AThen) then Exit; if not Assigned(AThen) then Exit;
if not (TPlatformServices.Current.SupportsPlatformService( if not BiometricAvailable then
IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate) then begin
AThen(boUnavailable);
Exit;
end;
if not TPlatformServices.Current.SupportsPlatformService(
IFMXBiometricAuthService, LSvc) then
begin begin
AThen(boUnavailable); AThen(boUnavailable);
Exit; Exit;
@@ -81,18 +95,25 @@ begin
LSvc.SetBiometricStrengths([TBiometricStrength.Strong, LSvc.SetBiometricStrengths([TBiometricStrength.Strong,
TBiometricStrength.DeviceCredential]); TBiometricStrength.DeviceCredential]);
LSvc.Authenticate( // Meme raison que plus haut : ce qui remonte de la couche Java doit
procedure // ressortir en TBioOutcome, jamais en exception - l'appelant a un chemin
begin // pour chaque issue, il n'en a aucun pour une exception.
AThen(boOK); try
end, LSvc.Authenticate(
procedure(const AFailReason: TBiometricFailReason; const AMessage: string) procedure
begin begin
if AFailReason = TBiometricFailReason.Cancelled then AThen(boOK);
AThen(boCancelled) end,
else procedure(const AFailReason: TBiometricFailReason; const AMessage: string)
AThen(boFailed); begin
end); if AFailReason = TBiometricFailReason.Cancelled then
AThen(boCancelled)
else
AThen(boFailed);
end);
except
AThen(boUnavailable);
end;
end; end;
{$ELSE} {$ELSE}
begin begin
+21
View File
@@ -213,6 +213,27 @@ Pour que le téléphone voie l'endpoint local :
adb reverse tcp:8080 tcp:80 adb reverse tcp:8080 tcp:80
``` ```
## Le piège de la porte sans clé
`USE_BIOMETRIC` **ne fait pas partie de la liste de permissions par défaut de
Delphi**. Sans elle, `CanAuthenticate` ne répond pas « pas de biométrie » : il
lève une `java.lang.SecurityException`. Le défaut ne se voit donc ni à la
compilation, ni au démarrage, mais à la première tentative d'ouverture — après
que le cache a déjà été écrit sous une clé protégée par authentification.
Le résultat est le pire cas possible : **un cache fermé derrière une porte que
l'application ne sait pas ouvrir**. Trois corrections, dont une seule est la
permission :
1. Les deux `uses-permission` dans le modèle de manifeste.
2. `BiometricAvailable` et `AuthenticateThen` attrapent ce qui remonte de la
couche Java. L'appelant a un chemin pour chaque `TBioOutcome`, il n'en a
aucun pour une exception.
3. **`SaveVault` ne reçoit plus `True` en dur** mais `BiometricAvailable`. On
ne demande une clé sous authentification que si l'invite peut réellement
s'afficher — sinon le cache serait illisible pour toujours, et la seule
sortie serait de l'effacer.
## Piège de compilation rencontré ## Piège de compilation rencontré
Cette installation de Delphi ne produisait **aucun** APK FireMonkey Cette installation de Delphi ne produisait **aucun** APK FireMonkey
+12 -5
View File
@@ -782,9 +782,17 @@ end;
procedure TForm8.FetchAndDecrypt(const AUrl, ATok, APwd: string; procedure TForm8.FetchAndDecrypt(const AUrl, ATok, APwd: string;
ASilent: Boolean); ASilent: Boolean);
var
LCanAuth: Boolean;
begin begin
SetBusy(True); SetBusy(True);
if not ASilent then Status('Telechargement...'); if not ASilent then Status('Telechargement...');
// NE JAMAIS FERMER LE CACHE DERRIERE UNE PORTE QU'ON NE SAIT PAS OUVRIR.
// Demander une cle protegee par authentification alors que l'invite ne peut
// pas s'afficher (permission absente, aucune protection configuree) produit
// un fichier definitivement illisible. Evalue ici, sur le thread principal :
// l'interrogation du service passe par la couche Java.
LCanAuth := BiometricAvailable;
TThread.CreateAnonymousThread( TThread.CreateAnonymousThread(
procedure procedure
var var
@@ -888,16 +896,15 @@ begin
// Ecrit APRES un dechiffrement reussi seulement, et avec son ETag : // Ecrit APRES un dechiffrement reussi seulement, et avec son ETag :
// les deux doivent rester coherents. // les deux doivent rester coherents.
try try
// True : on DEMANDE la protection par authentification. PMA.Store
// retombe tout seul sur une cle simple si l'appareil n'a ni
// empreinte ni code, plutot que de renoncer au cache.
// Le mot de passe part avec le coffre : c'est lui qui permettra au // Le mot de passe part avec le coffre : c'est lui qui permettra au
// prochain lancement de rafraichir sans rien demander. // prochain lancement de rafraichir sans rien demander.
SaveVault(LPlain, FETag, APwd, True); SaveVault(LPlain, FETag, APwd, LCanAuth);
if VaultNeedsAuth then if VaultNeedsAuth then
Log(' cache ecrit - protege par biometrie/code') Log(' cache ecrit - protege par biometrie/code')
else if LCanAuth then
Log(' cache ecrit - cle simple (Keystore a refuse la contrainte)')
else else
Log(' cache ecrit - AUCUNE protection sur cet appareil'); Log(' cache ecrit - AUCUNE protection : pas d''authentification ici');
except except
// La classe compte autant que le message : "methode introuvable" // La classe compte autant que le message : "methode introuvable"
// designe une signature JNI fausse, pas un probleme de stockage. // designe une signature JNI fausse, pas un probleme de stockage.