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
+66 -34
View File
@@ -28,7 +28,7 @@ const
//
// Le port differe selon la plateforme : sur Windows on attaque Apache
// directement (80), sur Android on passe par "adb reverse tcp:8080 tcp:80",
// qui fait voir le PC comme 127.0.0.1 — ce qui satisfait au passage
// qui fait voir le PC comme 127.0.0.1 - ce qui satisfait au passage
// l'exemption HTTPS de l'endpoint, reservee a la boucle locale.
{$IF DEFINED(ANDROID)}
TEST_URL = 'http://127.0.0.1:8080/server-php/sync.php';
@@ -36,7 +36,7 @@ const
TEST_URL = 'http://127.0.0.1/server-php/sync.php';
{$ENDIF}
// Jeton d'un serveur de test local. Il n'authentifie qu'un appareil et ne
// donne acces qu'a du chiffre — mais a remplacer avant toute mise en ligne.
// donne acces qu'a du chiffre - mais a remplacer avant toute mise en ligne.
TEST_TOKEN = '8765e5bf426039e9888f44bfbab27d489d12b7dbaaa54c8c695ae6af8b2a820e';
type
@@ -53,6 +53,7 @@ type
FETag: string;
procedure RunSelfTest(Sender: TObject);
procedure RunFetch(Sender: TObject);
procedure CountEntries(const APlainJson: string);
procedure Log(const AMsg: string);
public
constructor Create(AOwner: TComponent); override;
@@ -64,22 +65,22 @@ var
implementation
uses
System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient;
System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient, PMA.Store;
{$R *.fmx}
constructor TForm8.Create(AOwner: TComponent);
begin
inherited;
Caption := 'PM — crypto self-test';
Caption := 'PM - crypto self-test';
// ORDRE DE CREATION INVERSE : en FMX, des controles alignes en haut
// s'empilent a rebours de leur creation — le dernier cree se retrouve tout
// s'empilent a rebours de leur creation - le dernier cree se retrouve tout
// en haut. On cree donc du bas vers le haut pour obtenir URL, jeton, mot de
// passe, puis les deux boutons.
//
// Rien n'est persiste : c'est un banc d'essai, et le mot de passe de sync
// ouvre le coffre entier — il n'a pas a trainer sur le disque tant que le
// ouvre le coffre entier - il n'a pas a trainer sur le disque tant que le
// stockage Keystore n'est pas ecrit.
FBtn := TButton.Create(Self);
FBtn.Parent := Self;
@@ -137,6 +138,26 @@ begin
end);
end;
procedure TForm8.CountEntries(const APlainJson: string);
var
LJson: TJSONObject;
LArr: TJSONArray;
LLive, LTrash, I: Integer;
begin
LJson := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LJson = nil then Exit;
try
if not LJson.TryGetValue<TJSONArray>('entries', LArr) then Exit;
LLive := 0; LTrash := 0;
for I := 0 to LArr.Count - 1 do
if (LArr.Items[I] as TJSONObject).GetValue<Integer>('deleted', 0) = 1
then Inc(LTrash) else Inc(LLive);
Log(' ' + LLive.ToString + ' entrees, ' + LTrash.ToString + ' en corbeille');
finally
LJson.Free;
end;
end;
procedure TForm8.RunFetch(Sender: TObject);
var
LUrl, LTok, LPwd: string;
@@ -154,12 +175,20 @@ begin
var
LClient: TSyncClient;
LRes: TSyncResult;
LPlain: string;
LPlain, LCached, LCachedETag: string;
LSw: TStopwatch;
LJson: TJSONObject;
LArr: TJSONArray;
LLive, LTrash, I: Integer;
begin
// Le coffre local d'abord : s'il est la, on connait deja l'ETag et on
// evite les 4 secondes de derivation quand rien n'a change.
if LoadVault(LCached, LCachedETag) then
begin
FETag := LCachedETag;
Log('Coffre local : ' + Length(LCached).ToString +
' caracteres, ETag ' + Copy(LCachedETag, 1, 16) + '...');
end
else
Log('Pas de coffre local - premier telechargement');
LClient := TSyncClient.Create(LUrl, LTok);
// try/finally et NON un simple enchainement : les Exit des branches
// d'erreur ci-dessous sautent la fin de la procedure, ce qui laissait le
@@ -177,7 +206,17 @@ begin
case LRes.Status of
ssNotModified:
begin
Log(' ==> 304 : inchange, rien a re-dechiffrer');
// C'est ICI que le cache paie : pas de telechargement, pas de
// derivation, le coffre vient du fichier local.
Log(' ==> 304 : inchange');
if LCached <> '' then
begin
LPlain := LCached;
Log(' coffre servi depuis le cache local, 0 ms de derivation');
CountEntries(LPlain);
end
else
Log(' mais aucun coffre local - incoherence, effacer le cache');
Exit;
end;
ssNoSnapshot:
@@ -204,26 +243,19 @@ begin
LSw.Stop;
FETag := LRes.ETag;
if FETag <> '' then Log(' ETag retenu : ' + FETag);
// Ecrit APRES un dechiffrement reussi seulement, et avec son ETag :
// les deux doivent rester coherents.
try
SaveVault(LPlain, FETag);
Log(' coffre enregistre localement (chiffre par le Keystore)');
except
on E: Exception do
Log(' cache non ecrit : ' + E.Message);
end;
Log(' ' + LSw.ElapsedMilliseconds.ToString + ' ms pour ' +
Length(LPlain).ToString + ' caracteres');
// Compte vivant / corbeille : c'est ce que le vrai client devra
// afficher separement.
LJson := TJSONObject.ParseJSONValue(LPlain) as TJSONObject;
if LJson <> nil then
try
if LJson.TryGetValue<TJSONArray>('entries', LArr) then
begin
LLive := 0; LTrash := 0;
for I := 0 to LArr.Count - 1 do
if (LArr.Items[I] as TJSONObject).GetValue<Integer>('deleted', 0) = 1
then Inc(LTrash) else Inc(LLive);
Log(' ' + LLive.ToString + ' entrees, ' +
LTrash.ToString + ' en corbeille');
end;
finally
LJson.Free;
end;
CountEntries(LPlain);
except
on E: Exception do
Log('ERREUR ' + E.ClassName + ' : ' + E.Message);
@@ -272,7 +304,7 @@ begin
else
begin
Log(' attendu: ' + TESTVECTOR_KEY_HEX);
Log(' ==> PBKDF2 FAUX — verifier l''encodage UTF-8 du mot de passe');
Log(' ==> PBKDF2 FAUX - verifier l''encodage UTF-8 du mot de passe');
Exit; // inutile de tester AES avec une mauvaise cle
end;
@@ -293,11 +325,11 @@ begin
LKeyHex := LowerCase(THash.DigestAsString(LKey));
Log(' duree : ' + IntToStr(LSw.ElapsedMilliseconds) + ' ms');
if LKeyHex = TESTVECTOR_KEY_HEX then
Log(' ==> IDENTIQUE — adoptable, encodage UTF-8 confirme')
Log(' ==> IDENTIQUE - adoptable, encodage UTF-8 confirme')
else
begin
Log(' obtenu : ' + LKeyHex);
Log(' ==> DIFFERENT — encodage du char[] incompatible, garder le Pascal');
Log(' ==> DIFFERENT - encodage du char[] incompatible, garder le Pascal');
end;
end;
@@ -312,9 +344,9 @@ begin
' octets (attendu ' + IntToStr(TESTVECTOR_PLAIN_LEN) + ')');
Log(' sha256 : ' + LSha);
if LSha = TESTVECTOR_PLAIN_SHA then
Log(' ==> AES-GCM OK — la chaine complete est bonne')
Log(' ==> AES-GCM OK - la chaine complete est bonne')
else
Log(' ==> SHA DIFFERENT — comparer avec testvector.md');
Log(' ==> SHA DIFFERENT - comparer avec testvector.md');
Log('');
Log('Debut du clair : ' + Copy(LPlain, 1, 60));