Files
r-zakarya 773e47330f fix(android): ask for the device credential alone when no biometric is enrolled
The re-seal never ran and left no trace, because a branch not taken logs nothing. It logs now, verdict and reason, whether or not anything happens - the absence of a line was itself the thing that cost time.

And the verdict may have been wrong: asking canAuthenticate for BIOMETRIC_STRONG together with DEVICE_CREDENTIAL can fail outright on a device with a pattern and no enrolled fingerprint, instead of keeping what exists. It now falls back to the credential alone, which is all the key needs, and AuthenticateThen presents whichever set answered - offering something else would draw the same refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:35:30 +01:00

223 lines
7.4 KiB
ObjectPascal

unit PMA.Biometric;
{
Fine couche au-dessus de FMX.BiometricAuth.
On passe par le service FMX plutot que par BiometricPrompt en JNI direct :
le rappel de BiometricPrompt exige une classe Java implementant une
interface, donc un .jar a compiler et embarquer. FMX fournit deja ce pont.
MODELE RETENU : la cle du Keystore est creee avec une validite APRES
authentification (quelques dizaines de secondes). L'utilisateur s'authentifie,
puis la cle devient utilisable pendant ce delai. C'est plus simple qu'un
CryptoObject lie a chaque operation, et suffisant ici : la fenetre est courte
et l'application est au premier plan pendant ce temps.
L'APPEL DOIT SE FAIRE SUR LE THREAD PRINCIPAL - il affiche un dialogue
systeme. Le travail lourd (dechiffrement) part ensuite dans un thread.
}
interface
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
/// SANS refuser l'acces - a l'appelant de decider. Un telephone sans
/// verrouillage ne peut de toute facon pas proteger la cle.
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.
WANTED_STRENGTHS = [TBiometricStrength.Strong,
TBiometricStrength.DeviceCredential];
// Repli quand la combinaison ci-dessus est refusee. Observe sur un appareil
// avec schema et AUCUNE empreinte : selon les versions, demander
// BIOMETRIC_STRONG en meme temps que DEVICE_CREDENTIAL fait echouer
// l'ensemble au lieu de retenir ce qui existe. On redemande alors juste ce
// dont on a besoin.
CREDENTIAL_ONLY = [TBiometricStrength.DeviceCredential];
var
// Le jeu retenu par la derniere interrogation reussie. AuthenticateThen doit
// presenter EXACTEMENT celui-la : demander autre chose que ce qui a repondu
// « disponible » ramenerait le meme refus.
FStrengths: TBiometricStrengths = WANTED_STRENGTHS;
/// Recupere le service ET pose les forces d'authentification.
///
/// L'ORDRE N'EST PAS COSMETIQUE. Le service Android construit son masque
/// d'authentificateurs a partir de ces forces, et son champ demarre a
/// l'ensemble VIDE - le constructeur ne l'initialise pas. Interroger
/// CanAuthenticate avant de les poser revient donc a demander
/// canAuthenticate(0), qui ne repond jamais « disponible », quel que soit ce
/// que l'appareil sait faire.
function AcquireService(out ASvc: IFMXBiometricAuthService): Boolean;
begin
Result := TPlatformServices.Current.SupportsPlatformService(
IFMXBiometricAuthService, ASvc);
if Result then
ASvc.SetBiometricStrengths(FStrengths);
end;
{$ENDIF}
function BiometricAvailable: Boolean;
{$IF DEFINED(ANDROID)}
var
LSvc: IFMXBiometricAuthService;
begin
// 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.
FLastError := '';
try
FStrengths := WANTED_STRENGTHS;
if not AcquireService(LSvc) then
begin
FLastError := 'biometric service missing from the platform';
Exit(False);
end;
Result := LSvc.CanAuthenticate;
if not Result then
begin
// Second essai sans la biometrie forte : le schema seul suffit a
// deverrouiller la cle, et c'est tout ce qu'on demande.
FStrengths := CREDENTIAL_ONLY;
LSvc.SetBiometricStrengths(FStrengths);
Result := LSvc.CanAuthenticate;
if Result then
FLastError := 'device credential only (no biometric enrolled)'
else
begin
FStrengths := WANTED_STRENGTHS;
FLastError := 'CanAuthenticate: no usable protection';
end;
end;
except
on E: Exception do
begin
FLastError := E.ClassName + ': ' + E.Message;
Result := False;
end;
end;
end;
{$ELSE}
begin
Result := False;
end;
{$ENDIF}
procedure AuthenticateThen(const APrompt: string; const AThen: TProc<TBioOutcome>);
{$IF DEFINED(ANDROID)}
var
LSvc: IFMXBiometricAuthService;
LProps: TBiometricPromptProperties;
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
FLastError := 'biometric service missing from the platform';
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: no usable protection';
AThen(boUnavailable);
Exit;
end;
except
on E: Exception do
begin
FLastError := E.ClassName + ': ' + E.Message;
AThen(boFailed);
Exit;
end;
end;
LProps := Default(TBiometricPromptProperties);
LProps.Title := 'Unlock the vault';
LProps.Description := APrompt;
LProps.CancelButtonText := 'Cancel';
LSvc.SetPromptProperties(LProps);
// 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
on E: Exception do
begin
FLastError := E.ClassName + ': ' + E.Message;
AThen(boFailed);
end;
end;
end;
{$ELSE}
begin
if Assigned(AThen) then AThen(boUnavailable);
end;
{$ENDIF}
end.