Files
Password-Manager/android-client/PMA.Store.pas
T
r-zakarya 1e7477cdd6 fix(android): overload both Cipher.init declarations, restore CRLF line endings
Delphi refuses a second declaration when the first is not marked overload, so the three-argument init was reported as too many parameters. Also adds Androidapi.Jni to silence the non-inlined TJavaArray.Length hints.

My ASCII normalisation had rewritten the sources in LF, which RAD Studio flags on every unit; a .gitattributes rule now pins CRLF for Pascal sources so git does not undo it.
2026-08-15 00:49:37 +01:00

425 lines
16 KiB
ObjectPascal

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.
/// 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.
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.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
{$ENDIF}
const
VAULT_FILE = 'vault.bin';
// 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.
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 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;
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}']
// Les DEUX surcharges doivent porter la directive : Delphi refuse une
// seconde declaration si la premiere ne l'a pas.
procedure init(opmode: Integer; key: JKeyX); cdecl; overload;
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(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(LAlias)) then
Exit(LStore.getKey(StringToJString(LAlias), 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(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}
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
// 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;
// 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, 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));
// 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);
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) then Exit;
if not (LRaw[0] in [1, 2]) 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'));
// 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)));
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
on E: Exception do
begin
Result := False;
// 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;
{$ELSE}
begin
Result := False;
APlainJson := '';
AETag := '';
end;
{$ENDIF}
end.