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:
@@ -38,7 +38,15 @@ type
|
||||
function HasVault: Boolean;
|
||||
|
||||
/// 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
|
||||
/// reinstallation, fichier altere) - dans ce cas il faut retelecharger.
|
||||
@@ -59,7 +67,16 @@ uses
|
||||
|
||||
const
|
||||
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
|
||||
// plutot qu'importees : elles sont figees par la plateforme, et cela evite
|
||||
// une declaration JNI de plus.
|
||||
@@ -156,6 +173,10 @@ type
|
||||
function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
|
||||
function setEncryptionPaddings(paddings: TJavaObjectArray<JString>): 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;
|
||||
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
|
||||
/// lue : on obtient une poignee vers un objet que seul le materiel peut
|
||||
/// utiliser.
|
||||
function EnsureKey: JKeyX;
|
||||
function EnsureKey(ARequireAuth: Boolean): JKeyX;
|
||||
var
|
||||
LStore: JKeyStoreX;
|
||||
LGen: JKeyGenerator;
|
||||
LBuilder: JKeyGenParameterSpec_Builder;
|
||||
LModes, LPaddings: TJavaObjectArray<JString>;
|
||||
LAlias: string;
|
||||
begin
|
||||
if ARequireAuth then LAlias := KEY_ALIAS_BIO else LAlias := KEY_ALIAS;
|
||||
|
||||
LStore := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore'));
|
||||
LStore.load(nil); // le magasin Android ne prend pas de flux
|
||||
|
||||
if LStore.containsAlias(StringToJString(KEY_ALIAS)) then
|
||||
Exit(LStore.getKey(StringToJString(KEY_ALIAS), nil));
|
||||
if LStore.containsAlias(StringToJString(LAlias)) then
|
||||
Exit(LStore.getKey(StringToJString(LAlias), nil));
|
||||
|
||||
LModes := TJavaObjectArray<JString>.Create(1);
|
||||
LModes.Items[0] := StringToJString('GCM');
|
||||
@@ -235,24 +259,51 @@ begin
|
||||
LPaddings.Items[0] := StringToJString('NoPadding');
|
||||
|
||||
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.setEncryptionPaddings(LPaddings);
|
||||
LBuilder := LBuilder.setKeySize(256);
|
||||
if ARequireAuth then
|
||||
begin
|
||||
LBuilder := LBuilder.setUserAuthenticationRequired(True);
|
||||
LBuilder := LBuilder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS);
|
||||
end;
|
||||
|
||||
LGen := TJKeyGenerator.JavaClass.getInstance(
|
||||
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);
|
||||
Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair
|
||||
end;
|
||||
{$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)}
|
||||
var
|
||||
LWrap: TJSONObject;
|
||||
LPlain, LIv, LCt, LOut: TBytes;
|
||||
LCipher: JCipherX;
|
||||
LKey: JKeyX;
|
||||
LGotAuth: Boolean;
|
||||
begin
|
||||
// ETag et coffre chiffres ENSEMBLE : ils doivent rester coherents. Un ETag
|
||||
// qui survivrait a un coffre illisible ferait repondre 304 au serveur alors
|
||||
@@ -266,15 +317,31 @@ begin
|
||||
LWrap.Free;
|
||||
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.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
|
||||
// avec la meme cle romprait GCM.
|
||||
LIv := FromJava(LCipher.getIV);
|
||||
LCt := FromJava(LCipher.doFinal(ToJava(LPlain)));
|
||||
|
||||
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(LCt[0], LOut[1 + Length(LIv)], Length(LCt));
|
||||
TFile.WriteAllBytes(VaultPath, LOut);
|
||||
@@ -299,7 +366,8 @@ begin
|
||||
|
||||
try
|
||||
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);
|
||||
Move(LRaw[1], LIv[0], IV_LEN);
|
||||
@@ -307,7 +375,11 @@ begin
|
||||
Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt));
|
||||
|
||||
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)));
|
||||
LPlain := FromJava(LCipher.doFinal(ToJava(LCt)));
|
||||
|
||||
@@ -321,12 +393,21 @@ begin
|
||||
LWrap.Free;
|
||||
end;
|
||||
except
|
||||
// Cle disparue (reinstallation, effacement du Keystore) ou fichier
|
||||
// altere : on ne tente rien, l'appelant retelechargera.
|
||||
on E: Exception do
|
||||
begin
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user