perf(android): one key derivation per save, not two

Reported as "Save takes 6 to 8 seconds". A save decrypts the snapshot it just
pulled and re-encrypts the patched one - with the same password. That was two
full PBKDF2 runs at 600k iterations, four to six seconds each on this phone,
for two keys that protect the same file.

DecryptContainerEx now hands back the key, its salt and the iteration count,
and EncryptContainerWithKey reuses them. The IV is still drawn fresh every
time: reusing an IV with the same key breaks GCM entirely, whereas reusing the
salt merely yields the same key - which is the whole point. A salt slows an
attack on the password; it has no reason to differ between two versions of the
same file.

EncryptContainer keeps its old shape and now delegates, so there is a single
place that composes a container.

The log times both halves, so the next measurement says where the remaining
seconds are rather than inviting another guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 19:50:18 +01:00
parent 440bfd5536
commit f245cdcc38
2 changed files with 54 additions and 11 deletions
+48 -9
View File
@@ -91,6 +91,20 @@ function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes;
function EncryptContainer(const APlainJson, APassword: string; function EncryptContainer(const APlainJson, APassword: string;
AIterations: Integer = 600000): string; AIterations: Integer = 600000): string;
/// Comme DecryptContainer, mais rend AUSSI la cle derivee, son sel et le
/// nombre d'iterations. Sert au seul cas ou l'on rechiffre juste apres avoir
/// dechiffre : deriver deux fois le meme mot de passe coute deux fois quatre a
/// six secondes sur un telephone, pour un resultat identique.
function DecryptContainerEx(const AContainerJson, APassword: string;
out AKey, ASalt: TBytes; out AIterations: Integer): string;
/// Rechiffre avec une cle DEJA derivee. L'IV, lui, est retire a chaque appel :
/// reutiliser un IV avec la meme cle casse GCM entierement, alors que
/// reutiliser le sel ne fait que redonner la meme cle - ce qui est justement
/// le but.
function EncryptContainerWithKey(const APlainJson: string;
const AKey, ASalt: TBytes; AIterations: Integer): string;
implementation implementation
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
@@ -658,21 +672,32 @@ end;
function EncryptContainer(const APlainJson, APassword: string; function EncryptContainer(const APlainJson, APassword: string;
AIterations: Integer): string; AIterations: Integer): string;
var var
LObj: TJSONObject; LSalt, LKey: TBytes;
LSalt, LIv, LKey, LCt: TBytes;
begin begin
if APassword = '' then if APassword = '' then
raise EPMACrypto.Create('Refusing to encrypt with an empty password'); raise EPMACrypto.Create('Refusing to encrypt with an empty password');
// TIRES A CHAQUE APPEL, tous les deux. Reutiliser un IV avec la meme cle
// casse GCM entierement - ce n'est pas une precaution de style.
LSalt := SecureRandomBytes(32); LSalt := SecureRandomBytes(32);
LIv := SecureRandomBytes(12);
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, AIterations, 32); LKey := DeriveKeyPBKDF2Native(APassword, LSalt, AIterations, 32);
if LKey = nil then if LKey = nil then
LKey := DeriveKeyPBKDF2(APassword, LSalt, AIterations, 32); LKey := DeriveKeyPBKDF2(APassword, LSalt, AIterations, 32);
LCt := AesGcmEncrypt(LKey, LIv, TEncoding.UTF8.GetBytes(APlainJson)); Result := EncryptContainerWithKey(APlainJson, LKey, LSalt, AIterations);
end;
function EncryptContainerWithKey(const APlainJson: string;
const AKey, ASalt: TBytes; AIterations: Integer): string;
var
LObj: TJSONObject;
LIv, LCt: TBytes;
begin
if (Length(AKey) <> 32) or (Length(ASalt) = 0) then
raise EPMACrypto.Create('Cle ou sel absent pour le rechiffrement');
// L'IV EST TIRE A CHAQUE APPEL, le sel non. Reutiliser un IV avec la meme
// cle casse GCM entierement ; reutiliser le sel ne fait que redonner la meme
// cle, ce qui est justement l'economie recherchee - le sel ralentit une
// attaque sur le mot de passe, il n'a pas a differer d'une version a l'autre.
LIv := SecureRandomBytes(12);
LCt := AesGcmEncrypt(AKey, LIv, TEncoding.UTF8.GetBytes(APlainJson));
LObj := TJSONObject.Create; LObj := TJSONObject.Create;
try try
@@ -681,7 +706,7 @@ begin
LObj.AddPair('format', 'pm-encrypted-export-v1'); LObj.AddPair('format', 'pm-encrypted-export-v1');
LObj.AddPair('kdf', 'pbkdf2-sha256'); LObj.AddPair('kdf', 'pbkdf2-sha256');
LObj.AddPair('kdf_iterations', TJSONNumber.Create(AIterations)); LObj.AddPair('kdf_iterations', TJSONNumber.Create(AIterations));
LObj.AddPair('kdf_salt', B64NoWrap(LSalt)); LObj.AddPair('kdf_salt', B64NoWrap(ASalt));
LObj.AddPair('iv', B64NoWrap(LIv)); LObj.AddPair('iv', B64NoWrap(LIv));
LObj.AddPair('ciphertext', B64NoWrap(LCt)); LObj.AddPair('ciphertext', B64NoWrap(LCt));
LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"', LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"',
@@ -693,6 +718,15 @@ begin
end; end;
function DecryptContainer(const AContainerJson, APassword: string): string; function DecryptContainer(const AContainerJson, APassword: string): string;
var
LKey, LSalt: TBytes;
LIter: Integer;
begin
Result := DecryptContainerEx(AContainerJson, APassword, LKey, LSalt, LIter);
end;
function DecryptContainerEx(const AContainerJson, APassword: string;
out AKey, ASalt: TBytes; out AIterations: Integer): string;
var var
LObj: TJSONObject; LObj: TJSONObject;
LFormat, LKdf: string; LFormat, LKdf: string;
@@ -731,6 +765,11 @@ begin
LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32); LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32);
LPlain := AesGcmDecrypt(LKey, LIv, LCt); LPlain := AesGcmDecrypt(LKey, LIv, LCt);
Result := TEncoding.UTF8.GetString(LPlain); Result := TEncoding.UTF8.GetString(LPlain);
// Rendus a l'appelant : ils lui evitent une seconde derivation s'il
// rechiffre juste apres.
AKey := LKey;
ASalt := LSalt;
AIterations := LIterations;
finally finally
LObj.Free; LObj.Free;
end; end;
+6 -2
View File
@@ -558,7 +558,9 @@ begin
LClient: TSyncClient; LClient: TSyncClient;
LGet, LPut: TSyncResult; LGet, LPut: TSyncResult;
LPlain, LNew, LBody, LErr: string; LPlain, LNew, LBody, LErr: string;
LAttempt: Integer; LKey, LSalt: TBytes;
LIter, LAttempt: Integer;
LSw: TStopwatch;
LDone: Boolean; LDone: Boolean;
begin begin
LClient := TSyncClient.Create(FUrl, FToken); LClient := TSyncClient.Create(FUrl, FToken);
@@ -587,7 +589,9 @@ begin
end; end;
end; end;
LBody := EncryptContainer(LNew, FSyncPwd); LSw := TStopwatch.StartNew;
LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter);
Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds]));
LPut := LClient.Put(LBody, LGet.ETag, 'password change from phone'); LPut := LClient.Put(LBody, LGet.ETag, 'password change from phone');
if LPut.Status = ssOK then if LPut.Status = ssOK then
begin begin