bc89ec0010
It was in French because the conversation is, and I never asked. The desktop app the client mirrors says Add field, Attach file, Show previous versions - someone moving between the two would notice the seam. Every visible string moved: buttons, statuses, detail row captions, log lines and the biometric prompt, plus the messages the modules surface through LastStoreError and LastBiometricError. Code comments stay in French. They address the author, not the user, and that is already the language of CLAUDE.md and much of the repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
6.3 KiB
ObjectPascal
197 lines
6.3 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];
|
|
|
|
/// 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(WANTED_STRENGTHS);
|
|
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
|
|
if not AcquireService(LSvc) then
|
|
begin
|
|
FLastError := 'biometric service missing from the platform';
|
|
Exit(False);
|
|
end;
|
|
Result := LSvc.CanAuthenticate;
|
|
if not Result then
|
|
FLastError := 'CanAuthenticate: no usable protection';
|
|
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.
|