feat(android): protect the local vault with biometric or device credential

The Keystore key now requires authentication, valid for 30 s afterwards - short enough that an unattended phone is not exploitable, long enough to decrypt and display. Time-bound rather than a per-use CryptoObject: BiometricPrompt's callback would need a Java class compiled into a jar, while FMX already bridges it.

Two aliases, because the auth constraint is fixed when the key is created and cannot be added later. The file header records which key wrote it, so a read knows whether to authenticate first.

A device with neither fingerprint nor lock screen cannot hold such a key, so SaveVault falls back to the plain key rather than giving up the cache. And a failed authentication no longer wipes the cache: the vault is intact, it is the user who cancelled - erasing it would cost a download and 4 s of derivation for a dismissed prompt.
This commit is contained in:
r-zakarya
2026-08-15 00:42:50 +01:00
parent 5fdb7f914e
commit 9649dac7b2
4 changed files with 243 additions and 18 deletions
+103
View File
@@ -0,0 +1,103 @@
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
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;
/// 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
{$IF DEFINED(ANDROID)}
uses
FMX.BiometricAuth, FMX.Platform;
{$ENDIF}
function BiometricAvailable: Boolean;
{$IF DEFINED(ANDROID)}
var
LSvc: IFMXBiometricAuthService;
begin
Result := TPlatformServices.Current.SupportsPlatformService(
IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate;
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;
if not (TPlatformServices.Current.SupportsPlatformService(
IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate) then
begin
AThen(boUnavailable);
Exit;
end;
LProps := Default(TBiometricPromptProperties);
LProps.Title := 'Deverrouiller le coffre';
LProps.Description := APrompt;
LProps.NegativeButtonText := 'Annuler';
LSvc.SetPromptProperties(LProps);
// DeviceCredential inclus : un telephone sans empreinte enrolee mais avec
// un code de verrouillage reste utilisable, au lieu d'etre bloque.
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);
end;
{$ELSE}
begin
if Assigned(AThen) then AThen(boUnavailable);
end;
{$ENDIF}
end.
+95 -14
View File
@@ -38,7 +38,15 @@ type
function HasVault: Boolean; function HasVault: Boolean;
/// Ecrit le coffre et l'ETag qui l'accompagne, chiffres ensemble. /// Ecrit le coffre et l'ETag qui l'accompagne, chiffres ensemble.
procedure SaveVault(const APlainJson, AETag: string); /// ARequireAuth demande une cle deverrouillee par biometrie ou code. Si
/// l'appareil n'a aucune de ces protections, on retombe silencieusement sur
/// une cle sans contrainte : mieux vaut un cache lie au materiel que pas de
/// cache du tout. VaultNeedsAuth dira ensuite ce qui a reellement ete fait.
procedure SaveVault(const APlainJson, AETag: string; ARequireAuth: Boolean = False);
/// Le coffre local exige-t-il une authentification pour etre lu ? Lu dans
/// l'en-tete du fichier, sans dechiffrer quoi que ce soit.
function VaultNeedsAuth: Boolean;
/// Relit le coffre. False si absent ou illisible (cle perdue apres une /// Relit le coffre. False si absent ou illisible (cle perdue apres une
/// reinstallation, fichier altere) - dans ce cas il faut retelecharger. /// reinstallation, fichier altere) - dans ce cas il faut retelecharger.
@@ -59,7 +67,16 @@ uses
const const
VAULT_FILE = 'vault.bin'; VAULT_FILE = 'vault.bin';
KEY_ALIAS = 'pm_vault_key'; // DEUX alias : la contrainte d'authentification est figee a la creation de
// la cle et ne peut pas etre ajoutee apres coup. Basculer d'un mode a
// l'autre change donc de cle, ce qui rend l'ancien fichier illisible - il
// sera efface et retelecharge, sans perte.
KEY_ALIAS = 'pm_vault_key'; // sans authentification
KEY_ALIAS_BIO = 'pm_vault_key_bio'; // deverrouillee par biometrie/code
// Duree pendant laquelle la cle reste utilisable apres une authentification
// reussie. Assez pour dechiffrer et afficher, trop court pour qu'un
// telephone laisse sans surveillance reste exploitable.
AUTH_VALIDITY_SECONDS = 30;
// Constantes d'android.security.keystore.KeyProperties, reprises en dur // Constantes d'android.security.keystore.KeyProperties, reprises en dur
// plutot qu'importees : elles sont figees par la plateforme, et cela evite // plutot qu'importees : elles sont figees par la plateforme, et cela evite
// une declaration JNI de plus. // une declaration JNI de plus.
@@ -156,6 +173,10 @@ type
function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl; function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
function setEncryptionPaddings(paddings: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl; function setEncryptionPaddings(paddings: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
function setKeySize(keySize: Integer): JKeyGenParameterSpec_Builder; cdecl; function setKeySize(keySize: Integer): JKeyGenParameterSpec_Builder; cdecl;
function setUserAuthenticationRequired(required: Boolean): JKeyGenParameterSpec_Builder; cdecl;
// Deprecie depuis l'API 30 au profit de setUserAuthenticationParameters,
// mais toujours fonctionnel et nettement plus simple a declarer.
function setUserAuthenticationValidityDurationSeconds(seconds: Integer): JKeyGenParameterSpec_Builder; cdecl;
function build: JKeyGenParameterSpec; cdecl; function build: JKeyGenParameterSpec; cdecl;
end; end;
TJKeyGenParameterSpec_Builder = class(TJavaGenericImport<JKeyGenParameterSpec_BuilderClass, JKeyGenParameterSpec_Builder>) end; TJKeyGenParameterSpec_Builder = class(TJavaGenericImport<JKeyGenParameterSpec_BuilderClass, JKeyGenParameterSpec_Builder>) end;
@@ -216,18 +237,21 @@ end;
/// La cle du Keystore, creee a la premiere utilisation. Elle n'est jamais /// La cle du Keystore, creee a la premiere utilisation. Elle n'est jamais
/// lue : on obtient une poignee vers un objet que seul le materiel peut /// lue : on obtient une poignee vers un objet que seul le materiel peut
/// utiliser. /// utiliser.
function EnsureKey: JKeyX; function EnsureKey(ARequireAuth: Boolean): JKeyX;
var var
LStore: JKeyStoreX; LStore: JKeyStoreX;
LGen: JKeyGenerator; LGen: JKeyGenerator;
LBuilder: JKeyGenParameterSpec_Builder; LBuilder: JKeyGenParameterSpec_Builder;
LModes, LPaddings: TJavaObjectArray<JString>; LModes, LPaddings: TJavaObjectArray<JString>;
LAlias: string;
begin begin
if ARequireAuth then LAlias := KEY_ALIAS_BIO else LAlias := KEY_ALIAS;
LStore := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore')); LStore := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore'));
LStore.load(nil); // le magasin Android ne prend pas de flux LStore.load(nil); // le magasin Android ne prend pas de flux
if LStore.containsAlias(StringToJString(KEY_ALIAS)) then if LStore.containsAlias(StringToJString(LAlias)) then
Exit(LStore.getKey(StringToJString(KEY_ALIAS), nil)); Exit(LStore.getKey(StringToJString(LAlias), nil));
LModes := TJavaObjectArray<JString>.Create(1); LModes := TJavaObjectArray<JString>.Create(1);
LModes.Items[0] := StringToJString('GCM'); LModes.Items[0] := StringToJString('GCM');
@@ -235,24 +259,51 @@ begin
LPaddings.Items[0] := StringToJString('NoPadding'); LPaddings.Items[0] := StringToJString('NoPadding');
LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init( LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init(
StringToJString(KEY_ALIAS), PURPOSE_ENCRYPT or PURPOSE_DECRYPT); StringToJString(LAlias), PURPOSE_ENCRYPT or PURPOSE_DECRYPT);
LBuilder := LBuilder.setBlockModes(LModes); LBuilder := LBuilder.setBlockModes(LModes);
LBuilder := LBuilder.setEncryptionPaddings(LPaddings); LBuilder := LBuilder.setEncryptionPaddings(LPaddings);
LBuilder := LBuilder.setKeySize(256); LBuilder := LBuilder.setKeySize(256);
if ARequireAuth then
begin
LBuilder := LBuilder.setUserAuthenticationRequired(True);
LBuilder := LBuilder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS);
end;
LGen := TJKeyGenerator.JavaClass.getInstance( LGen := TJKeyGenerator.JavaClass.getInstance(
StringToJString('AES'), StringToJString('AndroidKeyStore')); StringToJString('AES'), StringToJString('AndroidKeyStore'));
// Leve si l'appareil n'a NI empreinte enrolee NI code de verrouillage : une
// cle protegee par authentification n'aurait alors rien pour la deverrouiller.
// L'appelant retombe sur la cle sans contrainte.
LGen.init(LBuilder.build); LGen.init(LBuilder.build);
Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair
end; end;
{$ENDIF} {$ENDIF}
procedure SaveVault(const APlainJson, AETag: string); function VaultNeedsAuth: Boolean;
var
LFs: TFileStream;
LVer: Byte;
begin
Result := False;
if not TFile.Exists(VaultPath) then Exit;
LFs := TFileStream.Create(VaultPath, fmOpenRead or fmShareDenyWrite);
try
if LFs.Size < 1 then Exit;
LFs.ReadBuffer(LVer, 1);
Result := LVer = 2; // 1 = cle simple, 2 = cle sous authentification
finally
LFs.Free;
end;
end;
procedure SaveVault(const APlainJson, AETag: string; ARequireAuth: Boolean);
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
var var
LWrap: TJSONObject; LWrap: TJSONObject;
LPlain, LIv, LCt, LOut: TBytes; LPlain, LIv, LCt, LOut: TBytes;
LCipher: JCipherX; LCipher: JCipherX;
LKey: JKeyX;
LGotAuth: Boolean;
begin begin
// ETag et coffre chiffres ENSEMBLE : ils doivent rester coherents. Un ETag // ETag et coffre chiffres ENSEMBLE : ils doivent rester coherents. Un ETag
// qui survivrait a un coffre illisible ferait repondre 304 au serveur alors // qui survivrait a un coffre illisible ferait repondre 304 au serveur alors
@@ -266,15 +317,31 @@ begin
LWrap.Free; LWrap.Free;
end; end;
// Repli explicite : un appareil sans empreinte NI code ne peut pas porter
// une cle protegee par authentification, et sa creation echouerait.
LGotAuth := False;
LKey := nil;
if ARequireAuth then
try
LKey := EnsureKey(True);
LGotAuth := True;
except
LKey := nil;
end;
if LKey = nil then
LKey := EnsureKey(False);
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureKey); LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, LKey);
// L'IV est GENERE par le Keystore, jamais choisi par nous : le reutiliser // L'IV est GENERE par le Keystore, jamais choisi par nous : le reutiliser
// avec la meme cle romprait GCM. // avec la meme cle romprait GCM.
LIv := FromJava(LCipher.getIV); LIv := FromJava(LCipher.getIV);
LCt := FromJava(LCipher.doFinal(ToJava(LPlain))); LCt := FromJava(LCipher.doFinal(ToJava(LPlain)));
SetLength(LOut, 1 + Length(LIv) + Length(LCt)); SetLength(LOut, 1 + Length(LIv) + Length(LCt));
LOut[0] := 1; // version de format // La version dit quelle cle a servi : sans elle, la relecture ne saurait
// pas s'il faut authentifier d'abord.
if LGotAuth then LOut[0] := 2 else LOut[0] := 1;
Move(LIv[0], LOut[1], Length(LIv)); Move(LIv[0], LOut[1], Length(LIv));
Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt)); Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt));
TFile.WriteAllBytes(VaultPath, LOut); TFile.WriteAllBytes(VaultPath, LOut);
@@ -299,7 +366,8 @@ begin
try try
LRaw := TFile.ReadAllBytes(VaultPath); LRaw := TFile.ReadAllBytes(VaultPath);
if (Length(LRaw) < 1 + IV_LEN + 16) or (LRaw[0] <> 1) then Exit; if (Length(LRaw) < 1 + IV_LEN + 16) then Exit;
if not (LRaw[0] in [1, 2]) then Exit;
SetLength(LIv, IV_LEN); SetLength(LIv, IV_LEN);
Move(LRaw[1], LIv[0], IV_LEN); Move(LRaw[1], LIv[0], IV_LEN);
@@ -307,7 +375,11 @@ begin
Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt)); Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt));
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureKey, // La cle est choisie d'apres l'en-tete. Si elle exige une
// authentification et qu'aucune n'a eu lieu recemment, l'init leve
// UserNotAuthenticatedException - attrapee plus bas, et le cache est
// efface. L'appelant doit donc authentifier AVANT (voir VaultNeedsAuth).
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureKey(LRaw[0] = 2),
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
LPlain := FromJava(LCipher.doFinal(ToJava(LCt))); LPlain := FromJava(LCipher.doFinal(ToJava(LCt)));
@@ -321,12 +393,21 @@ begin
LWrap.Free; LWrap.Free;
end; end;
except except
// Cle disparue (reinstallation, effacement du Keystore) ou fichier
// altere : on ne tente rien, l'appelant retelechargera.
on E: Exception do on E: Exception do
begin begin
Result := False; Result := False;
ClearVault; // NE PAS effacer sur un simple defaut d'authentification : le coffre est
// intact, c'est l'utilisateur qui n'a pas presente son empreinte ou qui
// a laisse expirer le delai. L'effacer imposerait un retelechargement et
// 4 secondes de derivation pour une empreinte annulee.
//
// On efface en revanche quand la cle est REELLEMENT perdue
// (reinstallation, Keystore vide, nouvelle empreinte enrolee qui
// invalide la cle) ou quand le fichier est altere : dans ces cas il ne
// redeviendra jamais lisible.
if (Pos('UserNotAuthenticated', E.Message) = 0) and
(Pos('NotAuthenticated', E.ClassName) = 0) then
ClearVault;
end; end;
end; end;
end; end;
+43 -3
View File
@@ -53,6 +53,7 @@ type
FETag: string; FETag: string;
procedure RunSelfTest(Sender: TObject); procedure RunSelfTest(Sender: TObject);
procedure RunFetch(Sender: TObject); procedure RunFetch(Sender: TObject);
procedure DoFetch(const LUrl, LTok, LPwd: string);
procedure CountEntries(const APlainJson: string); procedure CountEntries(const APlainJson: string);
procedure Log(const AMsg: string); procedure Log(const AMsg: string);
public public
@@ -65,7 +66,7 @@ var
implementation implementation
uses uses
System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient, PMA.Store; System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient, PMA.Store, PMA.Biometric;
{$R *.fmx} {$R *.fmx}
@@ -170,6 +171,39 @@ begin
LTok := FToken.Text.Trim; LTok := FToken.Text.Trim;
LPwd := FPwd.Text; LPwd := FPwd.Text;
// L'authentification affiche un dialogue systeme : elle doit rester sur le
// thread principal, et PRECEDER toute lecture du coffre. La declencher
// depuis le thread de travail ne montrerait rien.
if VaultNeedsAuth then
begin
Log('Coffre local protege - authentification requise');
AuthenticateThen('Acceder au coffre',
procedure(AOutcome: TBioOutcome)
begin
case AOutcome of
boOK: begin Log(' authentifie'); DoFetch(LUrl, LTok, LPwd); end;
boCancelled: begin Log(' annule'); FBtnFetch.Enabled := True; end;
boUnavailable:
begin
// La protection a ete retiree du telephone depuis l'ecriture du
// cache : il est devenu illisible pour toujours.
Log(' plus aucune protection sur l''appareil - cache efface');
ClearVault;
DoFetch(LUrl, LTok, LPwd);
end;
else
Log(' echec de l''authentification');
FBtnFetch.Enabled := True;
end;
end);
Exit;
end;
DoFetch(LUrl, LTok, LPwd);
end;
procedure TForm8.DoFetch(const LUrl, LTok, LPwd: string);
begin
TThread.CreateAnonymousThread( TThread.CreateAnonymousThread(
procedure procedure
var var
@@ -247,8 +281,14 @@ 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
SaveVault(LPlain, FETag); // True : on DEMANDE la protection par authentification. PMA.Store
Log(' coffre enregistre localement (chiffre par le Keystore)'); // retombe tout seul sur une cle simple si l'appareil n'a ni
// empreinte ni code, plutot que de renoncer au cache.
SaveVault(LPlain, FETag, True);
if VaultNeedsAuth then
Log(' coffre enregistre - protege par biometrie/code')
else
Log(' coffre enregistre - AUCUNE protection sur cet appareil');
except except
on E: Exception do on E: Exception do
Log(' cache non ecrit : ' + E.Message); Log(' cache non ecrit : ' + E.Message);
+2 -1
View File
@@ -7,7 +7,8 @@ uses
PMA.Crypto in 'android-client\PMA.Crypto.pas', PMA.Crypto in 'android-client\PMA.Crypto.pas',
PMA.TestVector in 'android-client\PMA.TestVector.pas', PMA.TestVector in 'android-client\PMA.TestVector.pas',
PMA.SyncClient in 'android-client\PMA.SyncClient.pas', PMA.SyncClient in 'android-client\PMA.SyncClient.pas',
PMA.Store in 'android-client\PMA.Store.pas'; PMA.Store in 'android-client\PMA.Store.pas',
PMA.Biometric in 'android-client\PMA.Biometric.pas';
{$R *.res} {$R *.res}