perf(android): the derived key is kept in the cache

Opening from the cache derives nothing, so the key learned last session was
gone and the FIRST save of every session paid the 4.5 seconds again. It now
travels in the sealed blob, next to the plaintext vault and to the password
that opens the container - that neighbourhood is what makes it acceptable:
it exposes nothing the file does not already hold.

The three parts go together or not at all. A key without its salt does not
say what it fits, which makes it useless, or worse, used where it does not
belong. A cache written by an earlier version carries none: LoadVault
returns nil, one derivation happens, and the next write stores it.

A freshly registered vault still derives once - EncryptContainer does not
hand back the key it just derived.

The plan records what the desktop confirmed: the two entries created on the
phone imported with nothing to correct.
This commit is contained in:
r-zakarya
2026-08-21 14:54:05 +01:00
parent 642dfdb077
commit 9a32fe8881
3 changed files with 103 additions and 12 deletions
+48 -3
View File
@@ -86,9 +86,19 @@ function HasVault: Boolean;
/// APin non vide : le cache est scelle sous ce code (VER_PIN), et ARequireAuth
/// est alors ignore - les deux protections ne se cumulent pas, et celle du code
/// est la seule qui survive a un changement de verrouillage d'ecran.
/// AKey/ASalt/AIterations : la cle DEJA DERIVEE du conteneur, rangee avec le
/// reste. Elle epargne 600 000 iterations - quatre a six secondes - au premier
/// enregistrement de chaque session, qui autrement rederivait un mot de passe
/// que ce meme fichier detient deja en clair juste a cote. C'est ce voisinage
/// qui rend l'ajout acceptable : le blob contient le coffre entier en clair et
/// le mot de passe qui ouvre le conteneur, donc la cle n'y ajoute rien.
///
/// Elle ne vaut que pour un conteneur portant CE sel et CE nombre
/// d'iterations ; les deux voyagent avec, et le lecteur verifie.
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth: Boolean = False; AFreshKey: Boolean = False;
const APin: string = '');
const APin: string = ''; const AKey: TBytes = nil;
const ASalt: TBytes = nil; AIterations: Integer = 0);
/// Le fichier local est-il scelle sous un code d'application ? Lu dans
/// l'en-tete, sans rien dechiffrer.
@@ -103,8 +113,12 @@ function VaultNeedsAuth: Boolean;
/// ASyncPwd est vide pour un fichier ecrit avant que le mot de passe y soit
/// range : l'appelant redemande alors, et le prochain enregistrement le pose.
function LoadVault(out APlainJson, AETag, ASyncPwd: string;
out AKey, ASalt: TBytes; out AIterations: Integer;
const APin: string = ''): Boolean;
/// AKey rend nil quand le cache a ete ecrit sans cle - un fichier d'avant, ou
/// une ecriture qui n'en tenait pas. L'appelant rederive alors, comme avant.
/// Pourquoi la derniere lecture a echoue. Vide apres un succes.
///
/// LoadVault N'EFFACE RIEN de lui-meme. Un cache detruit par une routine de
@@ -159,7 +173,9 @@ uses
// derive sa cle exactement comme le conteneur de sync, avec la meme
// implementation eprouvee contre le vecteur de test.
Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers,
PMA.Crypto;
// System.NetEncoding : la cle derivee voyage en base64 dans le blob, comme
// tout ce que le conteneur transporte.
System.NetEncoding, PMA.Crypto;
{$ENDIF}
const
@@ -825,7 +841,8 @@ begin
end;
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth, AFreshKey: Boolean; const APin: string);
ARequireAuth, AFreshKey: Boolean; const APin: string;
const AKey, ASalt: TBytes; AIterations: Integer);
{$IF DEFINED(ANDROID)}
var
LWrap: TJSONObject;
@@ -858,6 +875,14 @@ begin
LWrap.AddPair('etag', AETag);
LWrap.AddPair('pwd', ASyncPwd);
LWrap.AddPair('vault', APlainJson);
// Les trois ensemble ou aucun : une cle sans son sel ne dit pas a quoi
// elle convient, et serait donc inutilisable - ou pire, utilisee a tort.
if (AKey <> nil) and (ASalt <> nil) and (AIterations > 0) then
begin
LWrap.AddPair('kdf_key', TNetEncoding.Base64.EncodeBytesToString(AKey));
LWrap.AddPair('kdf_salt', TNetEncoding.Base64.EncodeBytesToString(ASalt));
LWrap.AddPair('kdf_iterations', TJSONNumber.Create(AIterations));
end;
LPlain := TEncoding.UTF8.GetBytes(LWrap.ToJSON);
finally
LWrap.Free;
@@ -987,6 +1012,7 @@ end;
{$ENDIF}
function LoadVault(out APlainJson, AETag, ASyncPwd: string;
out AKey, ASalt: TBytes; out AIterations: Integer;
const APin: string = ''): Boolean;
{$IF DEFINED(ANDROID)}
var
@@ -1000,6 +1026,9 @@ begin
APlainJson := '';
AETag := '';
ASyncPwd := '';
AKey := nil;
ASalt := nil;
AIterations := 0;
FLastStoreError := '';
if not TFile.Exists(VaultPath) then Exit;
@@ -1098,6 +1127,22 @@ begin
AETag := LWrap.GetValue<string>('etag', '');
ASyncPwd := LWrap.GetValue<string>('pwd', '');
APlainJson := LWrap.GetValue<string>('vault', '');
// Absents d'un cache ecrit avant que la cle y soit rangee : on rend nil,
// et l'appelant rederive comme il le faisait.
AIterations := LWrap.GetValue<Integer>('kdf_iterations', 0);
if AIterations > 0 then
begin
AKey := TNetEncoding.Base64.DecodeStringToBytes(
LWrap.GetValue<string>('kdf_key', ''));
ASalt := TNetEncoding.Base64.DecodeStringToBytes(
LWrap.GetValue<string>('kdf_salt', ''));
end;
if (AKey = nil) or (ASalt = nil) then
begin
AKey := nil;
ASalt := nil;
AIterations := 0;
end;
Result := APlainJson <> '';
finally
LWrap.Free;