From 4258e9b559333b0b35ace4761d3e9f1eaa2fa98e Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:09:29 +0100 Subject: [PATCH] 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 --- AndroidManifest.template.xml | 7 +++++ android-client/PMA.Biometric.pas | 53 ++++++++++++++++++++++---------- android-client/README.md | 21 +++++++++++++ mainApk.pas | 17 +++++++--- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/AndroidManifest.template.xml b/AndroidManifest.template.xml index c3a20c1..61006f1 100644 --- a/AndroidManifest.template.xml +++ b/AndroidManifest.template.xml @@ -8,6 +8,13 @@ android:installLocation="%installLocation%"> <%uses-permission%> + + + <%queries-child-elements%> diff --git a/android-client/PMA.Biometric.pas b/android-client/PMA.Biometric.pas index 9018096..6f413a6 100644 --- a/android-client/PMA.Biometric.pas +++ b/android-client/PMA.Biometric.pas @@ -47,8 +47,17 @@ function BiometricAvailable: Boolean; var LSvc: IFMXBiometricAuthService; begin - Result := TPlatformServices.Current.SupportsPlatformService( - IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate; + // try/except et non un simple test : CanAuthenticate LEVE une + // 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; {$ELSE} begin @@ -64,8 +73,13 @@ var begin if not Assigned(AThen) then Exit; - if not (TPlatformServices.Current.SupportsPlatformService( - IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate) then + if not BiometricAvailable then + begin + AThen(boUnavailable); + Exit; + end; + if not TPlatformServices.Current.SupportsPlatformService( + IFMXBiometricAuthService, LSvc) then begin AThen(boUnavailable); Exit; @@ -81,18 +95,25 @@ begin LSvc.SetBiometricStrengths([TBiometricStrength.Strong, TBiometricStrength.DeviceCredential]); - LSvc.Authenticate( - procedure - begin - AThen(boOK); - end, - procedure(const AFailReason: TBiometricFailReason; const AMessage: string) - begin - if AFailReason = TBiometricFailReason.Cancelled then - AThen(boCancelled) - else - AThen(boFailed); - end); + // Meme raison que plus haut : ce qui remonte de la couche Java doit + // ressortir en TBioOutcome, jamais en exception - l'appelant a un chemin + // pour chaque issue, il n'en a aucun pour une exception. + try + LSvc.Authenticate( + procedure + begin + AThen(boOK); + end, + procedure(const AFailReason: TBiometricFailReason; const AMessage: string) + begin + if AFailReason = TBiometricFailReason.Cancelled then + AThen(boCancelled) + else + AThen(boFailed); + end); + except + AThen(boUnavailable); + end; end; {$ELSE} begin diff --git a/android-client/README.md b/android-client/README.md index ceb886e..f214c10 100644 --- a/android-client/README.md +++ b/android-client/README.md @@ -213,6 +213,27 @@ Pour que le téléphone voie l'endpoint local : 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é Cette installation de Delphi ne produisait **aucun** APK FireMonkey diff --git a/mainApk.pas b/mainApk.pas index 4828c65..d0274c2 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -782,9 +782,17 @@ end; procedure TForm8.FetchAndDecrypt(const AUrl, ATok, APwd: string; ASilent: Boolean); +var + LCanAuth: Boolean; begin SetBusy(True); 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( procedure var @@ -888,16 +896,15 @@ begin // Ecrit APRES un dechiffrement reussi seulement, et avec son ETag : // les deux doivent rester coherents. 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 // prochain lancement de rafraichir sans rien demander. - SaveVault(LPlain, FETag, APwd, True); + SaveVault(LPlain, FETag, APwd, LCanAuth); if VaultNeedsAuth then Log(' cache ecrit - protege par biometrie/code') + else if LCanAuth then + Log(' cache ecrit - cle simple (Keystore a refuse la contrainte)') else - Log(' cache ecrit - AUCUNE protection sur cet appareil'); + Log(' cache ecrit - AUCUNE protection : pas d''authentification ici'); except // La classe compte autant que le message : "methode introuvable" // designe une signature JNI fausse, pas un probleme de stockage.