feat(android): keep the decrypted vault locally under an Android Keystore key

Opening a container costs ~4 s and the iteration count belongs to the desktop format, so the fix is to stop repeating it: derive once per snapshot, then read a local AES file which the hardware accelerates. The key is generated inside the Keystore and never leaves it - the file alone is useless, even rooted.

ETag and vault are encrypted together on purpose: an ETag outliving an unreadable vault would make the server answer 304 with nothing to show. A failed read wipes the cache rather than guessing.

Also normalises every Delphi source to pure ASCII: Delphi reads a BOM-less file as ANSI, so accented comments came back as mojibake, and each PowerShell rewrite added another layer.
This commit is contained in:
r-zakarya
2026-08-15 00:37:59 +01:00
parent 3c1525e46f
commit 5fdb7f914e
5 changed files with 422 additions and 48 deletions
+341
View File
@@ -0,0 +1,341 @@
unit PMA.Store;
{
Coffre garde localement, chiffre sous une cle du Android Keystore.
POURQUOI : ouvrir un conteneur coute ~4 s (PBKDF2 600k, incompressible - le
nombre d'iterations appartient au format ecrit par le poste Windows). Payer
ce prix a chaque ouverture de l'application serait absurde alors que le
contenu n'a pas change. On ne le paie donc qu'a l'arrivee d'un instantane
NOUVEAU, et on relit ensuite un fichier local chiffre en AES - accelere
materiellement, donc instantane.
LA CLE NE QUITTE JAMAIS LE MATERIEL. Elle est generee dans le Keystore et
n'en sort pas : on envoie des donnees a chiffrer, on recoit le resultat. Un
vol du fichier seul est donc inexploitable, meme root - il faut la puce.
CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair, une fois
dechiffre. D'ou trois regles tenues ici :
- jamais hors du repertoire prive de l'application ;
- jamais de copie en clair sur le disque, meme temporaire ;
- la sauvegarde automatique Android doit etre desactivee pour l'app
(android:allowBackup="false"), sinon Google en recupere une copie.
PAS ENCORE FAIT : exiger la biometrie a chaque lecture
(setUserAuthenticationRequired). La cle est deja liee au materiel ; la
biometrie ajouterait une barriere si le telephone est deverrouille et qu'un
tiers ouvre l'application. A faire dans un second temps, avec BiometricPrompt.
}
interface
uses
System.SysUtils, System.Classes, System.JSON, System.IOUtils;
type
EPMAStore = class(Exception);
/// Le coffre local existe-t-il ?
function HasVault: Boolean;
/// Ecrit le coffre et l'ETag qui l'accompagne, chiffres ensemble.
procedure SaveVault(const APlainJson, AETag: string);
/// Relit le coffre. False si absent ou illisible (cle perdue apres une
/// reinstallation, fichier altere) - dans ce cas il faut retelecharger.
function LoadVault(out APlainJson, AETag: string): Boolean;
/// Efface le coffre local. A appeler au moindre doute : il se retelecharge.
procedure ClearVault;
/// Chemin du fichier, pour le journal et les tests.
function VaultPath: string;
implementation
{$IF DEFINED(ANDROID)}
uses
Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
{$ENDIF}
const
VAULT_FILE = 'vault.bin';
KEY_ALIAS = 'pm_vault_key';
// 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.
PURPOSE_ENCRYPT = 1;
PURPOSE_DECRYPT = 2;
IV_LEN = 12; // GCM : 12 octets, la longueur recommandee
function VaultPath: string;
begin
// GetHomePath sur Android = /data/data/<paquet>/files, prive a l'app.
Result := TPath.Combine(TPath.GetHomePath, VAULT_FILE);
end;
function HasVault: Boolean;
begin
Result := TFile.Exists(VaultPath);
end;
procedure ClearVault;
begin
if TFile.Exists(VaultPath) then
TFile.Delete(VaultPath);
end;
{$IF DEFINED(ANDROID)}
type
JKeyStoreX = interface;
JSecretKeyX = interface;
JKeyGenerator = interface;
JKeyGenParameterSpec = interface;
JKeyGenParameterSpec_Builder = interface;
JCipherX = interface;
JGCMParameterSpecX = interface;
JAlgorithmParameterSpecX = interface;
JKeyX = interface;
JKeyXClass = interface(IJavaClass)
['{2C1A0B3D-4E5F-6A7B-8C9D-0E1F2A3B4C5D}']
end;
[JavaSignature('java/security/Key')]
JKeyX = interface(IJavaInstance)
['{3D2B1C4E-5F60-7B8C-9DAE-1F2A3B4C5D6E}']
end;
TJKeyX = class(TJavaGenericImport<JKeyXClass, JKeyX>) end;
JSecretKeyXClass = interface(JKeyXClass)
['{4E3C2D5F-6071-8C9D-AEBF-2A3B4C5D6E7F}']
end;
[JavaSignature('javax/crypto/SecretKey')]
JSecretKeyX = interface(JKeyX)
['{5F4D3E60-7182-9DAE-BFC0-3B4C5D6E7F80}']
end;
TJSecretKeyX = class(TJavaGenericImport<JSecretKeyXClass, JSecretKeyX>) end;
JAlgorithmParameterSpecXClass = interface(IJavaClass)
['{605E4F71-8293-AEBF-C0D1-4C5D6E7F8091}']
end;
[JavaSignature('java/security/spec/AlgorithmParameterSpec')]
JAlgorithmParameterSpecX = interface(IJavaInstance)
['{716F5082-93A4-BFC0-D1E2-5D6E7F8091A2}']
end;
TJAlgorithmParameterSpecX = class(TJavaGenericImport<JAlgorithmParameterSpecXClass, JAlgorithmParameterSpecX>) end;
JGCMParameterSpecXClass = interface(JAlgorithmParameterSpecXClass)
['{82706193-A4B5-C0D1-E2F3-6E7F8091A2B3}']
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpecX; cdecl;
end;
[JavaSignature('javax/crypto/spec/GCMParameterSpec')]
JGCMParameterSpecX = interface(JAlgorithmParameterSpecX)
['{938172A4-B5C6-D1E2-F304-7F8091A2B3C4}']
end;
TJGCMParameterSpecX = class(TJavaGenericImport<JGCMParameterSpecXClass, JGCMParameterSpecX>) end;
JKeyStoreXClass = interface(JObjectClass)
['{A49283B5-C6D7-E2F3-0415-8091A2B3C4D5}']
function getInstance(&type: JString): JKeyStoreX; cdecl;
end;
[JavaSignature('java/security/KeyStore')]
JKeyStoreX = interface(JObject)
['{B5A394C6-D7E8-F304-1526-91A2B3C4D5E6}']
procedure load(param: JObject); cdecl;
function getKey(alias: JString; password: TJavaArray<Char>): JKeyX; cdecl;
function containsAlias(alias: JString): Boolean; cdecl;
end;
TJKeyStoreX = class(TJavaGenericImport<JKeyStoreXClass, JKeyStoreX>) end;
JKeyGenParameterSpec_BuilderClass = interface(JObjectClass)
['{C6B4A5D7-E8F9-0415-2637-A2B3C4D5E6F7}']
function init(keystoreAlias: JString; purposes: Integer): JKeyGenParameterSpec_Builder; cdecl;
end;
[JavaSignature('android/security/keystore/KeyGenParameterSpec$Builder')]
JKeyGenParameterSpec_Builder = interface(JObject)
['{D7C5B6E8-F90A-1526-3748-B3C4D5E6F708}']
function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
function setEncryptionPaddings(paddings: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
function setKeySize(keySize: Integer): JKeyGenParameterSpec_Builder; cdecl;
function build: JKeyGenParameterSpec; cdecl;
end;
TJKeyGenParameterSpec_Builder = class(TJavaGenericImport<JKeyGenParameterSpec_BuilderClass, JKeyGenParameterSpec_Builder>) end;
JKeyGenParameterSpecClass = interface(JAlgorithmParameterSpecXClass)
['{E8D6C7F9-0A1B-2637-4859-C4D5E6F70819}']
end;
[JavaSignature('android/security/keystore/KeyGenParameterSpec')]
JKeyGenParameterSpec = interface(JAlgorithmParameterSpecX)
['{F9E7D80A-1B2C-3748-5960-D5E6F7081920}']
end;
TJKeyGenParameterSpec = class(TJavaGenericImport<JKeyGenParameterSpecClass, JKeyGenParameterSpec>) end;
JKeyGeneratorClass = interface(JObjectClass)
['{0AF8E91B-2C3D-4859-6A71-E6F708192031}']
function getInstance(algorithm: JString; provider: JString): JKeyGenerator; cdecl;
end;
[JavaSignature('javax/crypto/KeyGenerator')]
JKeyGenerator = interface(JObject)
['{1B09FA2C-3D4E-5960-7B82-F70819203142}']
procedure init(params: JAlgorithmParameterSpecX); cdecl;
function generateKey: JSecretKeyX; cdecl;
end;
TJKeyGenerator = class(TJavaGenericImport<JKeyGeneratorClass, JKeyGenerator>) end;
JCipherXClass = interface(JObjectClass)
['{2C1A0B3D-4E5F-6A71-8C93-081920314253}']
function _GetENCRYPT_MODE: Integer; cdecl;
function _GetDECRYPT_MODE: Integer; cdecl;
function getInstance(transformation: JString): JCipherX; cdecl;
property ENCRYPT_MODE: Integer read _GetENCRYPT_MODE;
property DECRYPT_MODE: Integer read _GetDECRYPT_MODE;
end;
[JavaSignature('javax/crypto/Cipher')]
JCipherX = interface(JObject)
['{3D2B1C4E-5F60-7B82-9DA4-192031425364}']
procedure init(opmode: Integer; key: JKeyX); cdecl;
procedure init(opmode: Integer; key: JKeyX; params: JAlgorithmParameterSpecX); cdecl; overload;
function doFinal(input: TJavaArray<Byte>): TJavaArray<Byte>; cdecl;
function getIV: TJavaArray<Byte>; cdecl;
end;
TJCipherX = class(TJavaGenericImport<JCipherXClass, JCipherX>) end;
function ToJava(const B: TBytes): TJavaArray<Byte>;
var I: Integer;
begin
Result := TJavaArray<Byte>.Create(Length(B));
for I := 0 to High(B) do Result.Items[I] := B[I];
end;
function FromJava(const A: TJavaArray<Byte>): TBytes;
var I: Integer;
begin
SetLength(Result, A.Length);
for I := 0 to A.Length - 1 do Result[I] := A.Items[I];
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;
var
LStore: JKeyStoreX;
LGen: JKeyGenerator;
LBuilder: JKeyGenParameterSpec_Builder;
LModes, LPaddings: TJavaObjectArray<JString>;
begin
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));
LModes := TJavaObjectArray<JString>.Create(1);
LModes.Items[0] := StringToJString('GCM');
LPaddings := TJavaObjectArray<JString>.Create(1);
LPaddings.Items[0] := StringToJString('NoPadding');
LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init(
StringToJString(KEY_ALIAS), PURPOSE_ENCRYPT or PURPOSE_DECRYPT);
LBuilder := LBuilder.setBlockModes(LModes);
LBuilder := LBuilder.setEncryptionPaddings(LPaddings);
LBuilder := LBuilder.setKeySize(256);
LGen := TJKeyGenerator.JavaClass.getInstance(
StringToJString('AES'), StringToJString('AndroidKeyStore'));
LGen.init(LBuilder.build);
Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair
end;
{$ENDIF}
procedure SaveVault(const APlainJson, AETag: string);
{$IF DEFINED(ANDROID)}
var
LWrap: TJSONObject;
LPlain, LIv, LCt, LOut: TBytes;
LCipher: JCipherX;
begin
// ETag et coffre chiffres ENSEMBLE : ils doivent rester coherents. Un ETag
// qui survivrait a un coffre illisible ferait repondre 304 au serveur alors
// que l'application n'a rien a afficher.
LWrap := TJSONObject.Create;
try
LWrap.AddPair('etag', AETag);
LWrap.AddPair('vault', APlainJson);
LPlain := TEncoding.UTF8.GetBytes(LWrap.ToJSON);
finally
LWrap.Free;
end;
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureKey);
// 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
Move(LIv[0], LOut[1], Length(LIv));
Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt));
TFile.WriteAllBytes(VaultPath, LOut);
end;
{$ELSE}
begin
raise EPMAStore.Create('Le stockage Keystore est propre a Android');
end;
{$ENDIF}
function LoadVault(out APlainJson, AETag: string): Boolean;
{$IF DEFINED(ANDROID)}
var
LRaw, LIv, LCt, LPlain: TBytes;
LCipher: JCipherX;
LWrap: TJSONObject;
begin
Result := False;
APlainJson := '';
AETag := '';
if not TFile.Exists(VaultPath) then Exit;
try
LRaw := TFile.ReadAllBytes(VaultPath);
if (Length(LRaw) < 1 + IV_LEN + 16) or (LRaw[0] <> 1) then Exit;
SetLength(LIv, IV_LEN);
Move(LRaw[1], LIv[0], IV_LEN);
SetLength(LCt, Length(LRaw) - 1 - IV_LEN);
Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt));
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureKey,
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
LPlain := FromJava(LCipher.doFinal(ToJava(LCt)));
LWrap := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(LPlain)) as TJSONObject;
if LWrap = nil then Exit;
try
AETag := LWrap.GetValue<string>('etag', '');
APlainJson := LWrap.GetValue<string>('vault', '');
Result := APlainJson <> '';
finally
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;
end;
end;
end;
{$ELSE}
begin
Result := False;
APlainJson := '';
AETag := '';
end;
{$ENDIF}
end.